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