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(crate) mod layout;
31
32mod io;
33pub(crate) use io::*;
34
35mod table_location;
36pub use table_location::*;
37pub(crate) use table_location::{
38 ensure_canonical_relative_storage_path, normalize_relative_storage_path,
39};
40
41mod output;
42pub use output::*;
43
44use snafu::IntoError;
45use std::path::PathBuf;
46
47/// General result type used by storage operations.
48///
49/// This aliases `Result<T, StorageError>` so functions in this module can
50/// return a concise result type while still communicating storage-specific
51/// error information via `StorageError`.
52pub type StorageResult<T> = Result<T, StorageError>;
53
54/// Backend + root location for storage operations.
55///
56/// This type represents the *root* of a storage backend (e.g. a local directory
57/// or an object-store prefix). It is intentionally generic and does **not**
58/// encode table-specific semantics; use `TableLocation` when you need a table
59/// root and table-scoped helpers.
60///
61/// Many storage helpers take a `StorageLocation` plus a relative path/key,
62/// which keeps backend roots and object paths separate and explicit.
63#[derive(Clone, Debug)]
64pub enum StorageLocation {
65 /// A local filesystem root at the given path.
66 Local(PathBuf),
67 // Future:
68 // S3 { bucket: string, prefix: string },
69}
70
71impl StorageLocation {
72 /// Creates a new `StorageLocation` for a local filesystem path.
73 pub fn local(root: impl Into<PathBuf>) -> Self {
74 StorageLocation::Local(root.into())
75 }
76
77 /// Parse a user-facing table location string into a StorageLocation.
78 /// v0.1: only local filesystem paths are supported.
79 pub fn parse(spec: &str) -> StorageResult<Self> {
80 let trimmed = spec.trim();
81 if trimmed.is_empty() {
82 return Err(OtherIoSnafu {
83 path: "<empty table location>".to_string(),
84 }
85 .into_error(StorageBackendError::from(std::io::Error::new(
86 std::io::ErrorKind::InvalidInput,
87 "table location is empty",
88 ))));
89 }
90
91 // Windows drive letter path (e.g. C:\ or C:/)
92 if trimmed.len() >= 2 {
93 let mut chars = trimmed.chars();
94 let first = chars.next();
95 let second = chars.next();
96 if let (Some(first), Some(second)) = (first, second)
97 && first.is_ascii_alphabetic()
98 && second == ':'
99 {
100 return Ok(StorageLocation::Local(PathBuf::from(trimmed)));
101 }
102 }
103
104 // URI-like scheme (e.g. s3://, gs://, https://)
105 let scheme = trimmed.split_once("://").and_then(|(scheme, _)| {
106 if scheme.is_empty() {
107 None
108 } else {
109 Some(scheme)
110 }
111 });
112
113 if let Some(scheme) = scheme {
114 let scheme_ok = scheme
115 .chars()
116 .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.');
117
118 if scheme_ok {
119 return Err(OtherIoSnafu {
120 path: trimmed.to_string(),
121 }
122 .into_error(StorageBackendError::from(std::io::Error::new(
123 std::io::ErrorKind::Unsupported,
124 format!("unsupported table location scheme: {scheme}"),
125 ))));
126 }
127 }
128
129 Ok(StorageLocation::Local(PathBuf::from(trimmed)))
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use std::io;
136
137 use super::*;
138
139 type TestResult = Result<(), Box<dyn std::error::Error>>;
140
141 #[test]
142 fn parse_rejects_empty_location() {
143 let err = StorageLocation::parse(" ").expect_err("expected error");
144 match err {
145 StorageError::OtherIo {
146 source: StorageBackendError::Filesystem { source },
147 ..
148 } => {
149 assert_eq!(source.kind(), io::ErrorKind::InvalidInput);
150 }
151 other => panic!("unexpected error: {other:?}"),
152 }
153 }
154
155 #[test]
156 fn parse_rejects_unsupported_scheme() {
157 let err =
158 StorageLocation::parse("s3://bucket/path").expect_err("expected unsupported scheme");
159 match err {
160 StorageError::OtherIo {
161 source: StorageBackendError::Filesystem { source },
162 ..
163 } => {
164 assert_eq!(source.kind(), io::ErrorKind::Unsupported);
165 }
166 other => panic!("unexpected error: {other:?}"),
167 }
168 }
169
170 #[test]
171 fn parse_accepts_local_path() -> TestResult {
172 let loc = StorageLocation::parse("/tmp/table")?;
173 match loc {
174 StorageLocation::Local(p) => {
175 assert_eq!(p, PathBuf::from("/tmp/table"));
176 }
177 }
178 Ok(())
179 }
180}