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
28/// Persistence-owned static path prefix used by streaming/session writers.
29pub trait CatalogPathPrefix {
30    /// Returns the record family prefix.
31    fn path_prefix() -> &'static str;
32}
33
34/// Normalizes Windows `\` separators to `/`.
35///
36/// Object stores and URIs always use forward slashes, while Windows local paths
37/// use backslashes. Normalizing before splitting or joining keeps path parsing
38/// identical on every platform.
39#[must_use]
40pub fn normalize_path_separators(path: &str) -> String {
41    path.replace('\\', "/")
42}
43
44/// Extracts path components using platform-appropriate path parsing.
45#[must_use]
46pub fn extract_path_components(path_str: &str) -> Vec<String> {
47    // Normalize separators and split
48    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/// Converts a local `PathBuf` to an object store path string.
57#[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/// Joins a base path and components into an object-store path with forward slashes.
63///
64/// Object stores (S3, GCS, etc.) always expect forward slashes regardless of platform.
65#[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/// Converts an instrument ID to a URI-safe format by removing forward slashes and replacing
98/// carets with underscores. Some instrument IDs contain forward slashes (e.g., "BTC/USD") which
99/// are not suitable for use in file paths.
100#[must_use]
101pub fn urisafe_instrument_id(instrument_id: &str) -> String {
102    instrument_id.replace('/', "").replace('^', "_")
103}
104
105/// Normalizes a user-supplied identifier for use in directory paths.
106///
107/// Replaces `//` with `/` and filters out empty segments and `..` to prevent path traversal.
108/// Backslashes are normalized first so Windows separators cannot bypass the filter.
109#[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/// Extracts the identifier from a file path: typically the second-to-last path component.
120///
121/// For example, from `data/quotes/EURUSD/file.parquet`, extracts `EURUSD`.
122/// Both `/` and `\` separators are recognized so Windows paths resolve identically.
123#[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/// Makes an identifier safe for use in SQL table names.
133///
134/// Keeps ASCII alphanumerics and underscores; replaces everything else with `_`, then lowercases.
135#[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
149/// Normalizes a path to URI format for consistent object store usage.
150///
151/// If the path is already a URI (contains "://"), returns it as-is.
152/// Otherwise, converts local paths to file:// URIs with proper cross-platform handling.
153///
154/// Supported URI schemes:
155/// - `s3://` for AWS S3
156/// - `gs://` or `gcs://` for Google Cloud Storage
157/// - `az://` or `abfs://` for Azure Blob Storage
158/// - `http://` or `https://` for HTTP/WebDAV
159/// - `file://` for local files
160///
161/// # Cross-platform Path Handling
162///
163/// - Unix absolute paths: `/path/to/file` → `file:///path/to/file`
164/// - Windows drive paths: `C:\path\to\file` → `file:///C:/path/to/file`
165/// - Windows UNC paths: `\\server\share\file` → `file://server/share/file`
166/// - Relative paths: converted to absolute using current directory
167///
168/// # Errors
169///
170/// Returns an error if the path is relative and the current working directory cannot be
171/// resolved.
172pub fn normalize_path_to_uri(path: &str) -> anyhow::Result<String> {
173    if path.contains("://") {
174        // Already a URI - return as-is
175        Ok(path.to_string())
176    } else if is_absolute_path(path) {
177        Ok(path_to_file_uri(path))
178    } else {
179        // Relative path - make it absolute first
180        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/// Checks if a path is absolute on any supported platform.
190#[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/// Converts an absolute path to a file:// URI with proper platform handling.
200#[must_use]
201pub(crate) fn path_to_file_uri(path: &str) -> String {
202    if path.starts_with('/') {
203        // Unix absolute path
204        format!("file://{path}")
205    } else if path.len() >= 3 && path.chars().nth(1) == Some(':') {
206        // Windows drive path - normalize separators and add proper prefix
207        let normalized = normalize_path_separators(path);
208        format!("file:///{normalized}")
209    } else if let Some(without_prefix) = path.strip_prefix("\\\\") {
210        // Windows UNC path \\server\share -> file://server/share
211        let normalized = normalize_path_separators(without_prefix);
212        format!("file://{normalized}")
213    } else {
214        // Fallback - treat as relative to root
215        format!("file://{path}")
216    }
217}
218
219/// Converts a file:// URI to a native path for the current platform.
220/// On Windows, "file:///C:/x/y" becomes "C:\x\y" so LocalFileSystem and std::fs work correctly.
221#[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    // Strip leading slash so "/C:/x/y" -> "C:/x/y", then use native separators
228    let without_leading = without_scheme.trim_start_matches('/');
229    without_leading.replace('/', "\\")
230}
231
232/// Converts a file:// URI to a path string for Unix (no-op; `object_store` accepts slash paths).
233#[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
238/// Returns the data-type segment of a Feather session path like
239/// `backtest/{run_id}/data/{type}/{...}/file.feather`.
240///
241/// Custom data is encoded as `data/custom/{type_name}/...`; the returned value
242/// is `custom/{type_name}` for those paths.
243///
244/// # Errors
245///
246/// Returns an error if the path does not contain the `{kind}/{instance_id}/...`
247/// prefix or does not have a recognizable type segment.
248pub(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
283/// Returns the optional identifier (instrument id, bar type, ...) segment of a
284/// Feather session path, or `None` if the path encodes only a type with no
285/// identifier (e.g. catalog-level files).
286pub(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        // Unix absolute paths
399        assert_eq!(
400            normalize_path_to_uri("/tmp/test").unwrap(),
401            "file:///tmp/test"
402        );
403
404        // Windows drive paths
405        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        // Windows UNC paths
419        assert_eq!(
420            normalize_path_to_uri("\\\\server\\share\\file").unwrap(),
421            "file://server/share/file"
422        );
423
424        // Already URIs - should remain unchanged
425        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        // Unix absolute paths
442        assert!(is_absolute_path("/tmp/test"));
443        assert!(is_absolute_path("/"));
444
445        // Windows drive paths
446        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        // Windows UNC paths
452        assert!(is_absolute_path("\\\\server\\share"));
453        assert!(is_absolute_path("\\\\localhost\\c$"));
454
455        // Relative paths
456        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        // Edge cases
462        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        // Unix absolute paths
471        assert_eq!(path_to_file_uri("/tmp/test"), "file:///tmp/test");
472        assert_eq!(path_to_file_uri("/"), "file:///");
473
474        // Windows drive paths
475        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        // Windows UNC paths
480        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        // Feather sessions are written under {kind}/{run_id}/{type}/{identifier?}/file.feather
493        // (no `data/` segment: that prefix only appears under the Delta catalog target
494        // layout, not under writer staging paths).
495        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}