Skip to main content

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}