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(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}