Skip to main content

cli/data/export_v2/
manifest.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Manifest data structures for Export/Import V2.
16
17use std::collections::HashSet;
18use std::time::Duration;
19use std::{fmt, str};
20
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use uuid::Uuid;
24
25use crate::data::export_v2::chunker::generate_chunks;
26use crate::data::export_v2::error::{
27    ChunkTimeWindowRequiresBoundsSnafu, Result as ExportResult, TimeParseEndBeforeStartSnafu,
28    TimeParseInvalidFormatSnafu,
29};
30
31/// Default standalone manifest version. Packed data snapshots use version 2.
32pub const MANIFEST_VERSION: u32 = 1;
33
34/// Manifest file name within snapshot directory.
35pub const MANIFEST_FILE: &str = "manifest.json";
36
37/// Time range for data export (half-open interval: [start, end)).
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct TimeRange {
40    /// Start time (inclusive). None means earliest available data.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub start: Option<DateTime<Utc>>,
43    /// End time (exclusive). None means current time.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub end: Option<DateTime<Utc>>,
46}
47
48impl TimeRange {
49    /// Creates a new time range with specified bounds.
50    pub fn new(start: Option<DateTime<Utc>>, end: Option<DateTime<Utc>>) -> Self {
51        Self { start, end }
52    }
53
54    /// Creates an unbounded time range (all data).
55    pub fn unbounded() -> Self {
56        Self {
57            start: None,
58            end: None,
59        }
60    }
61
62    /// Returns true if this time range is unbounded.
63    pub fn is_unbounded(&self) -> bool {
64        self.start.is_none() && self.end.is_none()
65    }
66
67    /// Returns true if both bounds are specified.
68    pub fn is_bounded(&self) -> bool {
69        self.start.is_some() && self.end.is_some()
70    }
71
72    /// Parses a time range from optional RFC3339 strings.
73    pub fn parse(start: Option<&str>, end: Option<&str>) -> ExportResult<Self> {
74        let start = start.map(parse_time).transpose()?;
75        let end = end.map(parse_time).transpose()?;
76
77        if let (Some(start), Some(end)) = (start, end)
78            && end < start
79        {
80            return TimeParseEndBeforeStartSnafu.fail();
81        }
82
83        Ok(Self::new(start, end))
84    }
85}
86
87fn parse_time(input: &str) -> ExportResult<DateTime<Utc>> {
88    DateTime::parse_from_rfc3339(input)
89        .map(|dt| dt.with_timezone(&Utc))
90        .map_err(|_| TimeParseInvalidFormatSnafu { input }.build())
91}
92
93impl Default for TimeRange {
94    fn default() -> Self {
95        Self::unbounded()
96    }
97}
98
99/// Status of a chunk during export/import.
100#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
101#[serde(rename_all = "snake_case")]
102pub enum ChunkStatus {
103    /// Chunk is pending export.
104    #[default]
105    Pending,
106    /// Chunk export is in progress.
107    InProgress,
108    /// Chunk export completed successfully.
109    Completed,
110    /// Chunk had no data to export.
111    Skipped,
112    /// Chunk export failed.
113    Failed,
114}
115
116/// Metadata for a single chunk of exported data.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct ChunkMeta {
119    /// Chunk identifier (sequential number starting from 1).
120    pub id: u32,
121    /// Time range covered by this chunk.
122    pub time_range: TimeRange,
123    /// Export status.
124    pub status: ChunkStatus,
125    /// List of data files in this chunk (relative paths from snapshot root).
126    #[serde(default)]
127    pub files: Vec<String>,
128    /// SHA256 checksum of all files in this chunk (aggregated).
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub checksum: Option<String>,
131    /// Error message if status is Failed.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub error: Option<String>,
134}
135
136impl ChunkMeta {
137    /// Creates a new pending chunk with the given id and time range.
138    pub fn new(id: u32, time_range: TimeRange) -> Self {
139        Self {
140            id,
141            time_range,
142            status: ChunkStatus::Pending,
143            files: vec![],
144            checksum: None,
145            error: None,
146        }
147    }
148
149    /// Creates a skipped chunk with the given id and time range.
150    pub fn skipped(id: u32, time_range: TimeRange) -> Self {
151        let mut chunk = Self::new(id, time_range);
152        chunk.mark_skipped();
153        chunk
154    }
155
156    /// Marks this chunk as in progress.
157    pub fn mark_in_progress(&mut self) {
158        self.status = ChunkStatus::InProgress;
159        self.error = None;
160    }
161
162    /// Marks this chunk as completed with the given files and checksum.
163    pub fn mark_completed(&mut self, files: Vec<String>, checksum: Option<String>) {
164        self.status = ChunkStatus::Completed;
165        self.files = files;
166        self.checksum = checksum;
167        self.error = None;
168    }
169
170    /// Marks this chunk as skipped because no data files were produced.
171    pub fn mark_skipped(&mut self) {
172        self.status = ChunkStatus::Skipped;
173        self.files.clear();
174        self.checksum = None;
175        self.error = None;
176    }
177
178    /// Marks this chunk as failed with the given error message.
179    pub fn mark_failed(&mut self, error: String) {
180        self.status = ChunkStatus::Failed;
181        self.error = Some(error);
182    }
183}
184
185/// Supported data formats for export.
186#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, clap::ValueEnum)]
187#[serde(rename_all = "lowercase")]
188#[value(rename_all = "lowercase")]
189pub enum DataFormat {
190    /// Apache Parquet format (default, recommended for production).
191    #[default]
192    Parquet,
193    /// CSV format (human-readable).
194    Csv,
195    /// JSON format (structured text).
196    Json,
197}
198
199impl fmt::Display for DataFormat {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        match self {
202            DataFormat::Parquet => write!(f, "parquet"),
203            DataFormat::Csv => write!(f, "csv"),
204            DataFormat::Json => write!(f, "json"),
205        }
206    }
207}
208
209impl str::FromStr for DataFormat {
210    type Err = String;
211
212    fn from_str(s: &str) -> Result<Self, Self::Err> {
213        match s.to_lowercase().as_str() {
214            "parquet" => Ok(DataFormat::Parquet),
215            "csv" => Ok(DataFormat::Csv),
216            "json" => Ok(DataFormat::Json),
217            _ => Err(format!(
218                "invalid format '{}': expected one of parquet, csv, json",
219                s
220            )),
221        }
222    }
223}
224
225/// Snapshot manifest containing all metadata.
226///
227/// The manifest is stored as `manifest.json` in the snapshot root directory.
228/// It contains:
229/// - Snapshot identification (UUID, timestamps)
230/// - Scope (catalog, schemas, time range)
231/// - Export configuration (format, schema_only)
232/// - Chunk metadata for resume support
233/// - Integrity checksums
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct Manifest {
236    /// Manifest format version for compatibility checking.
237    pub version: u32,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub data_layout: Option<String>,
240    /// Unique snapshot identifier.
241    pub snapshot_id: Uuid,
242    /// Catalog name.
243    pub catalog: String,
244    /// List of schemas included in this snapshot.
245    pub schemas: Vec<String>,
246    /// Overall time range covered by this snapshot.
247    pub time_range: TimeRange,
248    /// Whether this is a schema-only snapshot (no data).
249    pub schema_only: bool,
250    /// Data format used for export.
251    pub format: DataFormat,
252    /// Chunk metadata (empty for schema-only snapshots).
253    #[serde(default)]
254    pub chunks: Vec<ChunkMeta>,
255    /// Snapshot-level SHA256 checksum (aggregated from all chunks).
256    #[serde(skip_serializing_if = "Option::is_none")]
257    pub checksum: Option<String>,
258    /// Creation timestamp.
259    pub created_at: DateTime<Utc>,
260    /// Last updated timestamp.
261    pub updated_at: DateTime<Utc>,
262}
263
264impl Manifest {
265    /// Decodes version-specific required fields without changing version-1 defaults.
266    pub fn from_json(data: &[u8]) -> serde_json::Result<Self> {
267        use serde::de::Error;
268
269        let value: serde_json::Value = serde_json::from_slice(data)?;
270        if value["version"] == 2 {
271            let chunks = value["chunks"].as_array().ok_or_else(|| {
272                serde_json::Error::custom("version-2 manifest requires a chunks array")
273            })?;
274            for chunk in chunks {
275                if !chunk["files"].is_array() {
276                    return Err(serde_json::Error::custom(
277                        "version-2 chunk requires a files array",
278                    ));
279                }
280            }
281        }
282        serde_json::from_slice(data)
283    }
284
285    /// Validates the encoding contract before any snapshot operation.
286    pub fn validate_layout(&self) -> std::result::Result<(), String> {
287        match (self.version, self.data_layout.as_deref(), self.format) {
288            (1, None, _) => Ok(()),
289            (2, Some(common_datasource::packed_snapshot::PACKED_LAYOUT), DataFormat::Parquet) => {
290                if self.checksum.is_some() || self.chunks.iter().any(|c| c.checksum.is_some()) {
291                    return Err("version-2 checksums are unsupported".into());
292                }
293                if self.schema_only != self.chunks.is_empty() {
294                    return Err("version-2 schema_only requires empty chunks; data snapshots require chunks".into());
295                }
296                let schemas: HashSet<_> = self.schemas.iter().collect();
297                if schemas.len() != self.schemas.len() {
298                    return Err("duplicate version-2 schemas".into());
299                }
300                let mut ids = HashSet::new();
301                for chunk in &self.chunks {
302                    if chunk.id == 0 || !ids.insert(chunk.id) {
303                        return Err("version-2 chunk IDs must be unique and positive".into());
304                    }
305                    let files: HashSet<_> = chunk.files.iter().collect();
306                    if files.len() != chunk.files.len() {
307                        return Err("duplicate version-2 chunk files".into());
308                    }
309                    if chunk.status == ChunkStatus::Skipped && !chunk.files.is_empty() {
310                        return Err("skipped version-2 chunks must have no files".into());
311                    }
312                    for file in &chunk.files {
313                        if file.contains('\\')
314                            || file
315                                .split('/')
316                                .any(|part| part.is_empty() || part == "." || part == "..")
317                            || !self.schemas.iter().any(|schema| {
318                                file.starts_with(&crate::data::path::data_dir_for_schema_chunk(
319                                    schema, chunk.id,
320                                ))
321                            })
322                        {
323                            return Err(format!("invalid version-2 chunk file: {file}"));
324                        }
325                    }
326                }
327                Ok(())
328            }
329            _ => Err(format!(
330                "Manifest version mismatch or unsupported layout: version {}, layout {:?}, format {}",
331                self.version, self.data_layout, self.format
332            )),
333        }
334    }
335
336    pub fn is_packed(&self) -> bool {
337        self.version == 2
338    }
339
340    pub fn new_for_export(
341        catalog: String,
342        schemas: Vec<String>,
343        schema_only: bool,
344        time_range: TimeRange,
345        format: DataFormat,
346        chunk_time_window: Option<Duration>,
347    ) -> ExportResult<Self> {
348        if chunk_time_window.is_some() && !time_range.is_bounded() {
349            return ChunkTimeWindowRequiresBoundsSnafu.fail();
350        }
351
352        let mut manifest = if schema_only {
353            Self::new_schema_only(catalog, schemas)
354        } else {
355            Self::new_full(catalog, schemas, time_range, format)
356        };
357
358        if !schema_only {
359            manifest.chunks = match chunk_time_window {
360                Some(window) => generate_chunks(&manifest.time_range, window),
361                None => generate_single_chunk(&manifest.time_range),
362            };
363            manifest.touch();
364        }
365
366        Ok(manifest)
367    }
368
369    /// Creates a new manifest for schema-only export.
370    pub fn new_schema_only(catalog: String, schemas: Vec<String>) -> Self {
371        let now = Utc::now();
372        Self {
373            version: MANIFEST_VERSION,
374            data_layout: None,
375            snapshot_id: Uuid::new_v4(),
376            catalog,
377            schemas,
378            time_range: TimeRange::unbounded(),
379            schema_only: true,
380            format: DataFormat::Parquet,
381            chunks: vec![],
382            checksum: None,
383            created_at: now,
384            updated_at: now,
385        }
386    }
387
388    /// Creates a new manifest for full export with time range and format.
389    pub fn new_full(
390        catalog: String,
391        schemas: Vec<String>,
392        time_range: TimeRange,
393        format: DataFormat,
394    ) -> Self {
395        let now = Utc::now();
396        Self {
397            version: MANIFEST_VERSION,
398            data_layout: None,
399            snapshot_id: Uuid::new_v4(),
400            catalog,
401            schemas,
402            time_range,
403            schema_only: false,
404            format,
405            chunks: vec![],
406            checksum: None,
407            created_at: now,
408            updated_at: now,
409        }
410    }
411
412    /// Returns true if all chunks are completed (or if schema-only).
413    pub fn is_complete(&self) -> bool {
414        self.schema_only
415            || (!self.chunks.is_empty()
416                && self
417                    .chunks
418                    .iter()
419                    .all(|c| matches!(c.status, ChunkStatus::Completed | ChunkStatus::Skipped)))
420    }
421
422    /// Returns the number of pending chunks.
423    pub fn pending_count(&self) -> usize {
424        self.chunks
425            .iter()
426            .filter(|c| c.status == ChunkStatus::Pending)
427            .count()
428    }
429
430    /// Returns the number of in-progress chunks.
431    pub fn in_progress_count(&self) -> usize {
432        self.chunks
433            .iter()
434            .filter(|c| c.status == ChunkStatus::InProgress)
435            .count()
436    }
437
438    /// Returns the number of completed chunks.
439    pub fn completed_count(&self) -> usize {
440        self.chunks
441            .iter()
442            .filter(|c| c.status == ChunkStatus::Completed)
443            .count()
444    }
445
446    /// Returns the number of skipped chunks.
447    pub fn skipped_count(&self) -> usize {
448        self.chunks
449            .iter()
450            .filter(|c| c.status == ChunkStatus::Skipped)
451            .count()
452    }
453
454    /// Returns the number of failed chunks.
455    pub fn failed_count(&self) -> usize {
456        self.chunks
457            .iter()
458            .filter(|c| c.status == ChunkStatus::Failed)
459            .count()
460    }
461
462    /// Updates the `updated_at` timestamp to now.
463    pub fn touch(&mut self) {
464        self.updated_at = Utc::now();
465    }
466
467    /// Adds a chunk to the manifest.
468    pub fn add_chunk(&mut self, chunk: ChunkMeta) {
469        self.chunks.push(chunk);
470        self.touch();
471    }
472
473    /// Updates a chunk by id.
474    pub fn update_chunk(&mut self, id: u32, updater: impl FnOnce(&mut ChunkMeta)) {
475        if let Some(chunk) = self.chunks.iter_mut().find(|c| c.id == id) {
476            updater(chunk);
477            self.touch();
478        }
479    }
480}
481
482fn generate_single_chunk(time_range: &TimeRange) -> Vec<ChunkMeta> {
483    if let (Some(start), Some(end)) = (time_range.start, time_range.end) {
484        if start == end {
485            return vec![ChunkMeta::skipped(1, time_range.clone())];
486        }
487        if start > end {
488            return Vec::new();
489        }
490    }
491    vec![ChunkMeta::new(1, time_range.clone())]
492}
493
494#[cfg(test)]
495mod tests {
496    #[test]
497    fn packed_manifest_read_contract_preserves_legacy_writer() {
498        let mut manifest =
499            super::Manifest::new_schema_only("greptime".into(), vec!["public".into()]);
500        assert!(manifest.validate_layout().is_ok());
501        manifest.version = 2;
502        assert!(manifest.validate_layout().is_err());
503        manifest.data_layout = Some("metric-parquet-packs".into());
504        assert!(manifest.validate_layout().is_ok());
505        manifest.format = super::DataFormat::Csv;
506        assert!(manifest.validate_layout().is_err());
507        manifest.format = super::DataFormat::Parquet;
508        manifest.version = 3;
509        assert!(manifest.validate_layout().is_err());
510        manifest.version = 1;
511        assert!(manifest.validate_layout().is_err());
512    }
513
514    #[test]
515    fn packed_required_fields_and_checksums_preserve_v1_defaults() {
516        let mut manifest =
517            super::Manifest::new_schema_only("greptime".into(), vec!["public".into()]);
518        manifest
519            .chunks
520            .push(super::ChunkMeta::new(1, super::TimeRange::unbounded()));
521        let legacy = serde_json::to_value(manifest).unwrap();
522        for version in [1, 2] {
523            let mut value = legacy.clone();
524            value["version"] = version.into();
525            if version == 2 {
526                value["data_layout"] = "metric-parquet-packs".into();
527                value["schema_only"] = false.into();
528            }
529            let raw = serde_json::to_string(&value).unwrap();
530            for duplicate in [
531                format!(
532                    "{{\"checksum\":\"unsupported\",\"checksum\":null,{}",
533                    &raw[1..]
534                ),
535                format!("{{\"chunks\":[],{}", &raw[1..]),
536                raw.replacen("\"files\":[]", "\"files\":[],\"files\":[]", 1),
537                raw.replacen(
538                    "\"files\":[]",
539                    "\"checksum\":\"unsupported\",\"checksum\":null,\"files\":[]",
540                    1,
541                ),
542            ] {
543                assert!(super::Manifest::from_json(duplicate.as_bytes()).is_err());
544            }
545            for field in ["chunks", "files"] {
546                let mut missing = value.clone();
547                if field == "chunks" {
548                    missing.as_object_mut().unwrap().remove(field);
549                } else {
550                    missing["chunks"][0].as_object_mut().unwrap().remove(field);
551                }
552                assert_eq!(
553                    super::Manifest::from_json(&serde_json::to_vec(&missing).unwrap()).is_ok(),
554                    version == 1
555                );
556            }
557            for checksum in [
558                None,
559                Some(serde_json::Value::Null),
560                Some("".into()),
561                Some("sha256:abc".into()),
562            ] {
563                for chunk_level in [false, true] {
564                    let mut candidate = value.clone();
565                    if let Some(checksum) = &checksum {
566                        if chunk_level {
567                            candidate["chunks"][0]["checksum"] = checksum.clone();
568                        } else {
569                            candidate["checksum"] = checksum.clone();
570                        }
571                    }
572                    let decoded =
573                        super::Manifest::from_json(&serde_json::to_vec(&candidate).unwrap())
574                            .unwrap();
575                    assert_eq!(
576                        decoded.validate_layout().is_ok(),
577                        version == 1 || checksum.as_ref().is_none_or(serde_json::Value::is_null)
578                    );
579                }
580            }
581        }
582    }
583
584    use std::time::Duration;
585
586    use chrono::{TimeZone, Utc};
587
588    use super::*;
589
590    #[test]
591    fn test_time_range_serialization() {
592        let range = TimeRange::unbounded();
593        let json = serde_json::to_string(&range).unwrap();
594        assert_eq!(json, "{}");
595
596        let range: TimeRange = serde_json::from_str("{}").unwrap();
597        assert!(range.is_unbounded());
598    }
599
600    #[test]
601    fn test_manifest_schema_only() {
602        let manifest =
603            Manifest::new_schema_only("greptime".to_string(), vec!["public".to_string()]);
604
605        assert_eq!(manifest.version, MANIFEST_VERSION);
606        assert!(manifest.schema_only);
607        assert!(manifest.chunks.is_empty());
608        assert!(manifest.is_complete());
609    }
610
611    #[test]
612    fn test_generate_single_chunk_zero_width_range_is_skipped() {
613        let ts = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
614        let chunks = generate_single_chunk(&TimeRange::new(Some(ts), Some(ts)));
615
616        assert_eq!(chunks.len(), 1);
617        assert_eq!(chunks[0].status, ChunkStatus::Skipped);
618        assert_eq!(chunks[0].time_range.start, Some(ts));
619        assert_eq!(chunks[0].time_range.end, Some(ts));
620    }
621
622    #[test]
623    fn test_generate_single_chunk_invalid_range_is_empty() {
624        let start = Utc.with_ymd_and_hms(2025, 1, 1, 1, 0, 0).unwrap();
625        let end = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
626        let chunks = generate_single_chunk(&TimeRange::new(Some(start), Some(end)));
627
628        assert!(chunks.is_empty());
629    }
630
631    #[test]
632    fn test_manifest_full() {
633        let manifest = Manifest::new_full(
634            "greptime".to_string(),
635            vec!["public".to_string()],
636            TimeRange::unbounded(),
637            DataFormat::Parquet,
638        );
639
640        assert!(!manifest.schema_only);
641        assert!(manifest.chunks.is_empty());
642        assert!(!manifest.is_complete());
643    }
644
645    #[test]
646    fn test_data_format_parsing() {
647        assert_eq!(
648            "parquet".parse::<DataFormat>().unwrap(),
649            DataFormat::Parquet
650        );
651        assert_eq!("CSV".parse::<DataFormat>().unwrap(), DataFormat::Csv);
652        assert_eq!("JSON".parse::<DataFormat>().unwrap(), DataFormat::Json);
653        assert!("invalid".parse::<DataFormat>().is_err());
654    }
655
656    #[test]
657    fn test_chunk_status_transitions() {
658        let mut chunk = ChunkMeta::new(1, TimeRange::unbounded());
659        assert_eq!(chunk.status, ChunkStatus::Pending);
660
661        chunk.mark_in_progress();
662        assert_eq!(chunk.status, ChunkStatus::InProgress);
663
664        chunk.mark_completed(
665            vec!["file1.parquet".to_string()],
666            Some("abc123".to_string()),
667        );
668        assert_eq!(chunk.status, ChunkStatus::Completed);
669        assert_eq!(chunk.files.len(), 1);
670
671        chunk.mark_skipped();
672        assert_eq!(chunk.status, ChunkStatus::Skipped);
673        assert!(chunk.files.is_empty());
674    }
675
676    #[test]
677    fn test_manifest_is_complete_when_chunks_are_completed_or_skipped() {
678        let mut manifest = Manifest::new_full(
679            "greptime".to_string(),
680            vec!["public".to_string()],
681            TimeRange::unbounded(),
682            DataFormat::Parquet,
683        );
684        manifest.add_chunk(ChunkMeta::new(1, TimeRange::unbounded()));
685        manifest.add_chunk(ChunkMeta::new(2, TimeRange::unbounded()));
686
687        manifest.update_chunk(1, |chunk| {
688            chunk.mark_completed(vec!["a.parquet".to_string()], None)
689        });
690        manifest.update_chunk(2, |chunk| chunk.mark_skipped());
691
692        assert!(manifest.is_complete());
693        assert_eq!(manifest.completed_count(), 1);
694        assert_eq!(manifest.skipped_count(), 1);
695    }
696
697    #[test]
698    fn test_manifest_chunk_time_window_none_single_chunk() {
699        let start = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
700        let end = Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap();
701        let range = TimeRange::new(Some(start), Some(end));
702        let manifest = Manifest::new_for_export(
703            "greptime".to_string(),
704            vec!["public".to_string()],
705            false,
706            range.clone(),
707            DataFormat::Parquet,
708            None,
709        )
710        .unwrap();
711
712        assert_eq!(manifest.chunks.len(), 1);
713        assert_eq!(manifest.chunks[0].time_range, range);
714    }
715
716    #[test]
717    fn test_time_range_parse_requires_order() {
718        let result = TimeRange::parse(Some("2025-01-02T00:00:00Z"), Some("2025-01-01T00:00:00Z"));
719        assert!(result.is_err());
720    }
721
722    #[test]
723    fn test_new_for_export_with_chunk_window_requires_bounded_range() {
724        let result = Manifest::new_for_export(
725            "greptime".to_string(),
726            vec!["public".to_string()],
727            false,
728            TimeRange::new(
729                None,
730                Some(Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap()),
731            ),
732            DataFormat::Parquet,
733            Some(Duration::from_secs(3600)),
734        );
735        assert!(result.is_err());
736    }
737}