Skip to main content

tessera/
models.rs

1//! Public data models.
2//!
3//! Response models ([`DatasetsResponse`], [`PartitionsResponse`], …) are
4//! generated from the vendored OpenAPI spec at build time (`build.rs` — the
5//! generated code lives in `$OUT_DIR`, so it can never drift from the spec).
6//! This module re-exports them alongside hand-written ergonomic helpers
7//! ([`PartitionRef`], [`MonthSpan`]).
8
9/// Generated OpenAPI response models, exempted from lint scrutiny.
10#[allow(dead_code, clippy::all, clippy::pedantic)]
11mod generated {
12    include!(concat!(env!("OUT_DIR"), "/tessera_generated.rs"));
13}
14
15pub use generated::{
16    DatasetSummary, DatasetsResponse, DownloadResponse, ErrorBody, MonthRange, Partition,
17    PartitionsResponse,
18};
19
20use crate::error::TesseraError;
21
22/// Validate a `YYYY-MM` month string.
23///
24/// Matches the Python SDK's `^\d{4}-(0[1-9]|1[0-2])$` regex: a four-digit
25/// year, a hyphen, and a month in `01..=12`.
26pub(crate) fn validate_month(month: &str) -> Result<(), TesseraError> {
27    let digits = |bytes: &[u8]| bytes.iter().all(u8::is_ascii_digit);
28    let valid = month.len() == 7
29        && month.as_bytes()[4] == b'-'
30        && digits(&month.as_bytes()[0..4])
31        && digits(&month.as_bytes()[5..7]);
32    if !valid {
33        return Err(TesseraError::InvalidArgument(format!(
34            "month must be in YYYY-MM format, got {month:?}"
35        )));
36    }
37    let m: u8 = month[5..7].parse().expect("digits checked above");
38    if !(1..=12).contains(&m) {
39        return Err(TesseraError::InvalidArgument(format!(
40            "month must be in YYYY-MM format, got {month:?}"
41        )));
42    }
43    Ok(())
44}
45
46/// A fully-qualified reference to a single partition.
47///
48/// A partition is one `(asset, coin, month)` Parquet object, e.g.
49/// `gold_ohlcv_1m` / `BTC` / `2025-09`.
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51pub struct PartitionRef {
52    /// Dataset name, e.g. `gold_ohlcv_1m`.
53    pub asset: String,
54    /// Coin symbol, e.g. `BTC`.
55    pub coin: String,
56    /// Partition month, `YYYY-MM`.
57    pub month: String,
58}
59
60impl PartitionRef {
61    /// Create a reference, validating the month format.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`TesseraError::InvalidArgument`] when `month` is not `YYYY-MM`.
66    pub fn new(
67        asset: impl Into<String>,
68        coin: impl Into<String>,
69        month: impl Into<String>,
70    ) -> Result<Self, TesseraError> {
71        let month = month.into();
72        validate_month(&month)?;
73        Ok(Self {
74            asset: asset.into(),
75            coin: coin.into(),
76            month,
77        })
78    }
79
80    /// The object-storage key layout: `{asset}/coin={COIN}/month={YYYY-MM}.parquet`.
81    #[must_use]
82    pub fn object_key(&self) -> String {
83        format!(
84            "{}/coin={}/month={}.parquet",
85            self.asset, self.coin, self.month
86        )
87    }
88}
89
90/// An inclusive range of months, e.g. `MonthSpan::new("2025-01", "2025-09")?`.
91///
92/// Pass it anywhere a `month` argument is accepted to expand to every month
93/// in the range (inclusive of both endpoints).
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct MonthSpan {
96    /// First month in the span, inclusive.
97    pub start: String,
98    /// Last month in the span, inclusive.
99    pub end: String,
100}
101
102impl MonthSpan {
103    /// Create a span, validating both months and their ordering.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`TesseraError::InvalidArgument`] when either endpoint is not
108    /// `YYYY-MM`, or when `start` is after `end`.
109    pub fn new(start: impl Into<String>, end: impl Into<String>) -> Result<Self, TesseraError> {
110        let start = start.into();
111        let end = end.into();
112        validate_month(&start)?;
113        validate_month(&end)?;
114        if start > end {
115            return Err(TesseraError::InvalidArgument(format!(
116                "MonthSpan start {start:?} is after end {end:?}"
117            )));
118        }
119        Ok(Self { start, end })
120    }
121
122    /// Return the months in the span as a list of `YYYY-MM` strings.
123    #[must_use]
124    pub fn months(&self) -> Vec<String> {
125        let (mut year, mut month) = parse_ym(&self.start);
126        let (end_year, end_month) = parse_ym(&self.end);
127        let mut months = Vec::new();
128        while (year, month) <= (end_year, end_month) {
129            months.push(format!("{year:04}-{month:02}"));
130            month += 1;
131            if month > 12 {
132                month = 1;
133                year += 1;
134            }
135        }
136        months
137    }
138}
139
140impl IntoIterator for MonthSpan {
141    type Item = String;
142    type IntoIter = std::vec::IntoIter<String>;
143
144    /// Iterate the span's months (inclusive, crossing year boundaries).
145    fn into_iter(self) -> Self::IntoIter {
146        self.months().into_iter()
147    }
148}
149
150/// Parse `YYYY-MM` into `(year, month)` integers.
151///
152/// Callers must have run [`validate_month`] first; numeric parsing is
153/// infallible on validated input.
154fn parse_ym(month: &str) -> (i32, i32) {
155    (
156        month[0..4]
157            .parse()
158            .expect("validated month has a numeric year"),
159        month[5..7]
160            .parse()
161            .expect("validated month has a numeric month"),
162    )
163}
164
165/// Accepted shapes for a `coin` argument: one symbol or any iterable of them.
166pub trait IntoCoins {
167    /// Coerce into a non-empty list of coin symbols.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`TesseraError::InvalidArgument`] when the result is empty.
172    fn into_coins(self) -> Result<Vec<String>, TesseraError>;
173}
174
175/// Accepted shapes for a `month` argument: one month, a [`MonthSpan`], or any
176/// iterable of months.
177pub trait IntoMonths {
178    /// Coerce into a non-empty, validated list of `YYYY-MM` strings.
179    ///
180    /// # Errors
181    ///
182    /// Returns [`TesseraError::InvalidArgument`] when the result is empty or
183    /// any entry is not in `YYYY-MM` format.
184    fn into_months(self) -> Result<Vec<String>, TesseraError>;
185}
186
187impl IntoCoins for &str {
188    fn into_coins(self) -> Result<Vec<String>, TesseraError> {
189        Ok(vec![self.to_string()])
190    }
191}
192
193impl IntoCoins for String {
194    fn into_coins(self) -> Result<Vec<String>, TesseraError> {
195        Ok(vec![self])
196    }
197}
198
199impl IntoCoins for &String {
200    fn into_coins(self) -> Result<Vec<String>, TesseraError> {
201        self.as_str().into_coins()
202    }
203}
204
205impl<C: AsRef<str>> IntoCoins for Vec<C> {
206    fn into_coins(self) -> Result<Vec<String>, TesseraError> {
207        self.as_slice().into_coins()
208    }
209}
210
211impl<C: AsRef<str>> IntoCoins for &Vec<C> {
212    fn into_coins(self) -> Result<Vec<String>, TesseraError> {
213        self.as_slice().into_coins()
214    }
215}
216
217impl<C: AsRef<str>, const N: usize> IntoCoins for [C; N] {
218    fn into_coins(self) -> Result<Vec<String>, TesseraError> {
219        self.as_slice().into_coins()
220    }
221}
222
223impl<C: AsRef<str>, const N: usize> IntoCoins for &[C; N] {
224    fn into_coins(self) -> Result<Vec<String>, TesseraError> {
225        self.as_slice().into_coins()
226    }
227}
228
229impl IntoMonths for &str {
230    fn into_months(self) -> Result<Vec<String>, TesseraError> {
231        validate_month(self)?;
232        Ok(vec![self.to_string()])
233    }
234}
235
236impl IntoMonths for String {
237    fn into_months(self) -> Result<Vec<String>, TesseraError> {
238        validate_month(&self)?;
239        Ok(vec![self])
240    }
241}
242
243impl IntoMonths for &String {
244    fn into_months(self) -> Result<Vec<String>, TesseraError> {
245        self.as_str().into_months()
246    }
247}
248
249impl IntoMonths for MonthSpan {
250    fn into_months(self) -> Result<Vec<String>, TesseraError> {
251        Ok(self.months())
252    }
253}
254
255impl<C: AsRef<str>> IntoMonths for &[C] {
256    fn into_months(self) -> Result<Vec<String>, TesseraError> {
257        let months: Vec<String> = self.iter().map(|m| m.as_ref().to_string()).collect();
258        if months.is_empty() {
259            return Err(TesseraError::InvalidArgument(
260                "at least one month is required".to_string(),
261            ));
262        }
263        for m in &months {
264            validate_month(m)?;
265        }
266        Ok(months)
267    }
268}
269
270impl<C: AsRef<str>> IntoMonths for Vec<C> {
271    fn into_months(self) -> Result<Vec<String>, TesseraError> {
272        self.as_slice().into_months()
273    }
274}
275
276impl<C: AsRef<str>> IntoMonths for &Vec<C> {
277    fn into_months(self) -> Result<Vec<String>, TesseraError> {
278        self.as_slice().into_months()
279    }
280}
281
282impl<C: AsRef<str>, const N: usize> IntoMonths for [C; N] {
283    fn into_months(self) -> Result<Vec<String>, TesseraError> {
284        self.as_slice().into_months()
285    }
286}
287
288impl<C: AsRef<str>, const N: usize> IntoMonths for &[C; N] {
289    fn into_months(self) -> Result<Vec<String>, TesseraError> {
290        self.as_slice().into_months()
291    }
292}
293
294impl<C: AsRef<str>> IntoCoins for &[C] {
295    fn into_coins(self) -> Result<Vec<String>, TesseraError> {
296        let coins: Vec<String> = self.iter().map(|c| c.as_ref().to_string()).collect();
297        if coins.is_empty() {
298            return Err(TesseraError::InvalidArgument(
299                "at least one coin is required".to_string(),
300            ));
301        }
302        Ok(coins)
303    }
304}