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}