Skip to main content

timeseries_table_format/
storage.rs

1//! Filesystem layout and path utilities.
2//!
3//! This module centralizes all filesystem- and path-related logic for
4//! `timeseries-table-format`. It is responsible for mapping a table root
5//! directory to the locations of:
6//!
7//! - The metadata log directory (for example, `<root>/_timeseries_log/`).
8//! - Individual commit files (for example, `<root>/_timeseries_log/0000000001.json`).
9//! - The `CURRENT` pointer that records the latest committed version.
10//! - Data segments (for example, Parquet files) and any directory
11//!   structure used to organize them.
12//!
13//! Goals of this module include:
14//!
15//! - Keeping path conventions in one place so they can be evolved
16//!   without touching higher-level logic.
17//! - Providing small helpers for atomic file operations used by the
18//!   commit protocol (for example, write-then-rename semantics).
19//! - Ensuring that higher-level modules (`log`, `table`) work with
20//!   strongly-typed paths and simple helpers instead of hard-coded
21//!   string concatenation.
22//!
23//! This module does not impose any particular storage backend beyond
24//! the local filesystem yet, but the API should be designed so that
25//! future adapters (for example, object storage) can be introduced
26//! without rewriting the log and table logic.
27mod error;
28pub use error::*;
29
30pub mod layout;
31
32mod io;
33pub use io::*;
34
35mod table_location;
36pub(crate) use table_location::normalize_relative_storage_path;
37pub use table_location::*;
38
39mod output;
40pub use output::*;
41
42use snafu::IntoError;
43use std::path::PathBuf;
44
45/// General result type used by storage operations.
46///
47/// This aliases `Result<T, StorageError>` so functions in this module can
48/// return a concise result type while still communicating storage-specific
49/// error information via `StorageError`.
50pub type StorageResult<T> = Result<T, StorageError>;
51
52/// Backend + root location for storage operations.
53///
54/// This type represents the *root* of a storage backend (e.g. a local directory
55/// or an object-store prefix). It is intentionally generic and does **not**
56/// encode table-specific semantics; use `TableLocation` when you need a table
57/// root and table-scoped helpers.
58///
59/// Many storage helpers take a `StorageLocation` plus a relative path/key,
60/// which keeps backend roots and object paths separate and explicit.
61#[derive(Clone, Debug)]
62pub enum StorageLocation {
63    /// A local filesystem root at the given path.
64    Local(PathBuf),
65    // Future:
66    // S3 { bucket: string, prefix: string },
67}
68
69impl StorageLocation {
70    /// Creates a new `StorageLocation` for a local filesystem path.
71    pub fn local(root: impl Into<PathBuf>) -> Self {
72        StorageLocation::Local(root.into())
73    }
74
75    /// Parse a user-facing table location string into a StorageLocation.
76    /// v0.1: only local filesystem paths are supported.
77    pub fn parse(spec: &str) -> StorageResult<Self> {
78        let trimmed = spec.trim();
79        if trimmed.is_empty() {
80            return Err(OtherIoSnafu {
81                path: "<empty table location>".to_string(),
82            }
83            .into_error(BackendError::Local(std::io::Error::new(
84                std::io::ErrorKind::InvalidInput,
85                "table location is empty",
86            ))));
87        }
88
89        // Windows drive letter path (e.g. C:\ or C:/)
90        if trimmed.len() >= 2 {
91            let mut chars = trimmed.chars();
92            let first = chars.next();
93            let second = chars.next();
94            if let (Some(first), Some(second)) = (first, second)
95                && first.is_ascii_alphabetic()
96                && second == ':'
97            {
98                return Ok(StorageLocation::Local(PathBuf::from(trimmed)));
99            }
100        }
101
102        // URI-like scheme (e.g. s3://, gs://, https://)
103        let scheme = trimmed.split_once("://").and_then(|(scheme, _)| {
104            if scheme.is_empty() {
105                None
106            } else {
107                Some(scheme)
108            }
109        });
110
111        if let Some(scheme) = scheme {
112            let scheme_ok = scheme
113                .chars()
114                .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.');
115
116            if scheme_ok {
117                return Err(OtherIoSnafu {
118                    path: trimmed.to_string(),
119                }
120                .into_error(BackendError::Local(std::io::Error::new(
121                    std::io::ErrorKind::Unsupported,
122                    format!("unsupported table location scheme: {scheme}"),
123                ))));
124            }
125        }
126
127        Ok(StorageLocation::Local(PathBuf::from(trimmed)))
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use std::io;
134
135    use super::*;
136
137    type TestResult = Result<(), Box<dyn std::error::Error>>;
138
139    #[test]
140    fn parse_rejects_empty_location() {
141        let err = StorageLocation::parse("   ").expect_err("expected error");
142        match err {
143            StorageError::OtherIo { source, .. } => match source {
144                BackendError::Local(inner) => {
145                    assert_eq!(inner.kind(), io::ErrorKind::InvalidInput);
146                }
147            },
148            other => panic!("unexpected error: {other:?}"),
149        }
150    }
151
152    #[test]
153    fn parse_rejects_unsupported_scheme() {
154        let err =
155            StorageLocation::parse("s3://bucket/path").expect_err("expected unsupported scheme");
156        match err {
157            StorageError::OtherIo { source, .. } => match source {
158                BackendError::Local(inner) => {
159                    assert_eq!(inner.kind(), io::ErrorKind::Unsupported);
160                }
161            },
162            other => panic!("unexpected error: {other:?}"),
163        }
164    }
165
166    #[test]
167    fn parse_accepts_local_path() -> TestResult {
168        let loc = StorageLocation::parse("/tmp/table")?;
169        match loc {
170            StorageLocation::Local(p) => {
171                assert_eq!(p, PathBuf::from("/tmp/table"));
172            }
173        }
174        Ok(())
175    }
176}