Skip to main content

cli/data/export_v2/
command.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//! Export V2 CLI commands.
16
17use std::collections::HashSet;
18use std::io::{self, Write};
19use std::time::Duration;
20
21use async_trait::async_trait;
22use clap::{Parser, Subcommand};
23use common_error::ext::BoxedError;
24use common_telemetry::info;
25use serde_json::Value;
26use snafu::ResultExt;
27
28use crate::Tool;
29use crate::common::ObjectStoreConfig;
30use crate::data::export_v2::coordinator::{ExportDataOptions, export_data};
31use crate::data::export_v2::error::{
32    ChunkTimeWindowRequiresBoundsSnafu, DatabaseSnafu, IoSnafu, ManifestVersionMismatchSnafu,
33    MetricExportRequiresParquetSnafu, Result, ResumeConfigMismatchSnafu,
34    SchemaOnlyArgsNotAllowedSnafu, SchemaOnlyModeMismatchSnafu, SnapshotVerifyFailedSnafu,
35    UnexpectedValueTypeSnafu,
36};
37use crate::data::export_v2::extractor::SchemaExtractor;
38use crate::data::export_v2::manifest::{
39    ChunkMeta, ChunkStatus, DataFormat, MANIFEST_FILE, MANIFEST_VERSION, Manifest, TimeRange,
40};
41use crate::data::export_v2::schema::{DDL_DIR, SCHEMA_DIR, SCHEMAS_FILE};
42use crate::data::path::{data_dir_for_schema_chunk, ddl_path_for_schema};
43use crate::data::progress::{ProgressMode, build_progress_reporter};
44use crate::data::schema_export::append_schema_ddl;
45use crate::data::snapshot_storage::{
46    OpenDalStorage, SnapshotStorage, validate_snapshot_uri, validate_uri,
47};
48use crate::data::sql::escape_sql_literal;
49use crate::database::{DatabaseClient, parse_proxy_opts};
50
51/// Export V2 commands.
52#[derive(Debug, Subcommand)]
53pub enum ExportV2Command {
54    /// Create a new snapshot.
55    Create(ExportCreateCommand),
56    /// List snapshots under a parent location.
57    List(ExportListCommand),
58    /// Verify snapshot integrity.
59    Verify(ExportVerifyCommand),
60    /// Delete a snapshot and all data under it.
61    Delete(ExportDeleteCommand),
62}
63
64impl ExportV2Command {
65    pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
66        match self {
67            ExportV2Command::Create(cmd) => cmd.build().await,
68            ExportV2Command::List(cmd) => cmd.build().await,
69            ExportV2Command::Verify(cmd) => cmd.build().await,
70            ExportV2Command::Delete(cmd) => cmd.build().await,
71        }
72    }
73}
74
75/// List snapshots under a parent location.
76#[derive(Debug, Parser)]
77pub struct ExportListCommand {
78    /// Parent storage location whose direct subdirectories are snapshots.
79    #[clap(long)]
80    location: String,
81
82    /// Object store configuration for remote storage backends.
83    #[clap(flatten)]
84    storage: ObjectStoreConfig,
85}
86
87impl ExportListCommand {
88    pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
89        validate_uri(&self.location).map_err(BoxedError::new)?;
90        let storage = OpenDalStorage::from_parent_uri(&self.location, &self.storage)
91            .map_err(BoxedError::new)?;
92
93        Ok(Box::new(ExportList {
94            location: self.location.clone(),
95            storage,
96        }))
97    }
98}
99
100/// Export list tool implementation.
101pub struct ExportList {
102    location: String,
103    storage: OpenDalStorage,
104}
105
106#[async_trait]
107impl Tool for ExportList {
108    async fn do_work(&self) -> std::result::Result<(), BoxedError> {
109        self.run().await.map_err(BoxedError::new)
110    }
111}
112
113impl ExportList {
114    async fn run(&self) -> Result<()> {
115        let result = scan_snapshots(&self.storage).await?;
116
117        println!("Scanning: {}", self.location);
118        if result.snapshots.is_empty() {
119            println!("No snapshots found.");
120        } else {
121            print_snapshot_list(&result.snapshots, result.unreadable.len());
122        }
123        print_unreadable_warnings(&result.unreadable);
124
125        Ok(())
126    }
127}
128
129/// Verify snapshot integrity.
130#[derive(Debug, Parser)]
131pub struct ExportVerifyCommand {
132    /// Snapshot storage location (e.g., s3://bucket/path, file:///tmp/backup).
133    #[clap(long)]
134    snapshot: String,
135
136    /// Object store configuration for remote storage backends.
137    #[clap(flatten)]
138    storage: ObjectStoreConfig,
139}
140
141impl ExportVerifyCommand {
142    pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
143        validate_uri(&self.snapshot).map_err(BoxedError::new)?;
144        let storage =
145            OpenDalStorage::from_uri(&self.snapshot, &self.storage).map_err(BoxedError::new)?;
146
147        Ok(Box::new(ExportVerify {
148            snapshot: self.snapshot.clone(),
149            storage,
150        }))
151    }
152}
153
154/// Export verify tool implementation.
155pub struct ExportVerify {
156    snapshot: String,
157    storage: OpenDalStorage,
158}
159
160#[async_trait]
161impl Tool for ExportVerify {
162    async fn do_work(&self) -> std::result::Result<(), BoxedError> {
163        self.run().await.map_err(BoxedError::new)
164    }
165}
166
167impl ExportVerify {
168    async fn run(&self) -> Result<()> {
169        let report = verify_snapshot(&self.storage).await?;
170        print_verify_report(&self.snapshot, &report);
171
172        if report.has_problems() {
173            return SnapshotVerifyFailedSnafu {
174                errors: report.error_count(),
175                warnings: report.warning_count(),
176            }
177            .fail();
178        }
179
180        Ok(())
181    }
182}
183
184/// Delete a snapshot and all data under it.
185#[derive(Debug, Parser)]
186pub struct ExportDeleteCommand {
187    /// Snapshot storage location (e.g., s3://bucket/path, file:///tmp/backup).
188    #[clap(long)]
189    snapshot: String,
190
191    /// Skip interactive confirmation.
192    #[clap(long = "no-confirm", alias = "yes")]
193    skip_confirmation: bool,
194
195    /// Object store configuration for remote storage backends.
196    #[clap(flatten)]
197    storage: ObjectStoreConfig,
198}
199
200impl ExportDeleteCommand {
201    pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
202        validate_snapshot_uri(&self.snapshot).map_err(BoxedError::new)?;
203        let storage =
204            OpenDalStorage::from_uri(&self.snapshot, &self.storage).map_err(BoxedError::new)?;
205
206        Ok(Box::new(ExportDelete {
207            snapshot: self.snapshot.clone(),
208            skip_confirmation: self.skip_confirmation,
209            storage,
210        }))
211    }
212}
213
214/// Export delete tool implementation.
215pub struct ExportDelete {
216    snapshot: String,
217    skip_confirmation: bool,
218    storage: OpenDalStorage,
219}
220
221#[async_trait]
222impl Tool for ExportDelete {
223    async fn do_work(&self) -> std::result::Result<(), BoxedError> {
224        self.run().await.map_err(BoxedError::new)
225    }
226}
227
228impl ExportDelete {
229    async fn run(&self) -> Result<()> {
230        self.run_with_confirmation(confirm_delete).await
231    }
232
233    async fn run_with_confirmation<F>(&self, confirm: F) -> Result<()>
234    where
235        F: FnOnce(&str) -> Result<bool>,
236    {
237        let manifest = self.storage.read_manifest().await?;
238        print_delete_summary(&self.snapshot, &manifest);
239
240        if !self.skip_confirmation && !confirm(&self.snapshot)? {
241            println!("Deletion cancelled.");
242            return Ok(());
243        }
244
245        println!("Deleting snapshot...");
246        self.storage.delete_snapshot().await?;
247        println!("Snapshot deleted successfully.");
248
249        Ok(())
250    }
251}
252
253/// Create a new snapshot.
254#[derive(Debug, Parser)]
255pub struct ExportCreateCommand {
256    /// Server address to connect (e.g., 127.0.0.1:4000).
257    #[clap(long)]
258    addr: String,
259
260    /// Target storage location (e.g., s3://bucket/path, file:///tmp/backup).
261    #[clap(long)]
262    to: String,
263
264    /// Catalog name.
265    #[clap(long, default_value = "greptime")]
266    catalog: String,
267
268    /// Schema list to export (default: all non-system schemas).
269    /// Can be specified multiple times or comma-separated.
270    #[clap(long, value_delimiter = ',')]
271    schemas: Vec<String>,
272
273    /// Export schema only, no data.
274    #[clap(long)]
275    schema_only: bool,
276
277    /// Time range start (ISO 8601 format, e.g., 2024-01-01T00:00:00Z).
278    #[clap(long)]
279    start_time: Option<String>,
280
281    /// Time range end (ISO 8601 format, e.g., 2024-12-31T23:59:59Z).
282    #[clap(long)]
283    end_time: Option<String>,
284
285    /// Chunk time window (e.g., 1h, 6h, 1d, 7d).
286    /// Requires both --start-time and --end-time when specified.
287    #[clap(long, value_parser = humantime::parse_duration)]
288    chunk_time_window: Option<Duration>,
289
290    /// Data format: parquet, csv, json.
291    #[clap(long, value_enum, default_value = "parquet")]
292    format: DataFormat,
293
294    /// Use shared Metric physical scans and packed objects (Parquet only).
295    /// Resume uses the manifest layout and requires that previous export and storage
296    /// writes have ended; an HTTP timeout does not establish this.
297    #[clap(long)]
298    pack_metric_data: bool,
299
300    /// Delete existing snapshot and recreate.
301    #[clap(long)]
302    force: bool,
303
304    /// Parallelism for COPY DATABASE execution (server-side, per schema per chunk).
305    #[clap(long, default_value = "1")]
306    parallelism: usize,
307
308    /// Number of export chunks to run concurrently on the client (1..=64).
309    #[clap(long, default_value = "1", value_parser = parse_chunk_parallelism)]
310    chunk_parallelism: usize,
311
312    /// Basic authentication (user:password).
313    #[clap(long)]
314    auth_basic: Option<String>,
315
316    /// Request timeout.
317    #[clap(long, value_parser = humantime::parse_duration)]
318    timeout: Option<Duration>,
319
320    /// Proxy server address.
321    ///
322    /// If set, it overrides the system proxy unless `--no-proxy` is specified.
323    /// If neither `--proxy` nor `--no-proxy` is set, system proxy (env) may be used.
324    #[clap(long)]
325    proxy: Option<String>,
326
327    /// Disable all proxy usage (ignores `--proxy` and system proxy).
328    ///
329    /// When set and `--proxy` is not provided, this explicitly disables system proxy.
330    #[clap(long)]
331    no_proxy: bool,
332
333    /// Progress reporting mode.
334    #[clap(long, value_enum, default_value_t = ProgressMode::Auto)]
335    progress: ProgressMode,
336
337    /// Object store configuration for remote storage backends.
338    #[clap(flatten)]
339    storage: ObjectStoreConfig,
340}
341
342impl ExportCreateCommand {
343    pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
344        // Validate URI format
345        validate_uri(&self.to).map_err(BoxedError::new)?;
346
347        let time_range = TimeRange::parse(self.start_time.as_deref(), self.end_time.as_deref())
348            .map_err(BoxedError::new)?;
349        if self.pack_metric_data && time_range.is_bounded() && time_range.start == time_range.end {
350            return crate::error::InvalidArgumentsSnafu {
351                msg: "Packed export requires --start-time to be earlier than --end-time",
352            }
353            .fail()
354            .map_err(BoxedError::new);
355        }
356        if self.chunk_time_window.is_some() && !time_range.is_bounded() {
357            return ChunkTimeWindowRequiresBoundsSnafu
358                .fail()
359                .map_err(BoxedError::new);
360        }
361        if self.schema_only {
362            let mut invalid_args = Vec::new();
363            if self.start_time.is_some() {
364                invalid_args.push("--start-time");
365            }
366            if self.end_time.is_some() {
367                invalid_args.push("--end-time");
368            }
369            if self.chunk_time_window.is_some() {
370                invalid_args.push("--chunk-time-window");
371            }
372            if self.format != DataFormat::Parquet {
373                invalid_args.push("--format");
374            }
375            if self.parallelism != 1 {
376                invalid_args.push("--parallelism");
377            }
378            if self.chunk_parallelism != 1 {
379                invalid_args.push("--chunk-parallelism");
380            }
381            if !invalid_args.is_empty() {
382                return SchemaOnlyArgsNotAllowedSnafu {
383                    args: invalid_args.join(", "),
384                }
385                .fail()
386                .map_err(BoxedError::new);
387            }
388        }
389
390        // Parse schemas (empty vec means all schemas)
391        let schemas = if self.schemas.is_empty() {
392            None
393        } else {
394            Some(self.schemas.clone())
395        };
396
397        // Build database client
398        let proxy = parse_proxy_opts(self.proxy.clone(), self.no_proxy)?;
399        let database_client = DatabaseClient::new(
400            self.addr.clone(),
401            self.catalog.clone(),
402            self.auth_basic.clone(),
403            self.timeout.unwrap_or(Duration::from_secs(60)),
404            proxy,
405            self.no_proxy,
406        );
407
408        // The filesystem storage constructor can create the snapshot root.
409        if self.pack_metric_data {
410            require_metric_export_format(self.format).map_err(BoxedError::new)?;
411            if !self.schema_only {
412                database_client
413                    .require_packed_export()
414                    .await
415                    .context(DatabaseSnafu)
416                    .map_err(BoxedError::new)?;
417            }
418        }
419        let storage = OpenDalStorage::from_uri(&self.to, &self.storage).map_err(BoxedError::new)?;
420
421        Ok(Box::new(ExportCreate {
422            config: ExportConfig {
423                catalog: self.catalog.clone(),
424                schemas,
425                schema_only: self.schema_only,
426                format: self.format,
427                packed: self.pack_metric_data,
428                force: self.force,
429                time_range,
430                chunk_time_window: self.chunk_time_window,
431                parallelism: self.parallelism,
432                chunk_parallelism: self.chunk_parallelism,
433                progress: self.progress,
434                snapshot_uri: self.to.clone(),
435                storage_config: self.storage.clone(),
436            },
437            storage: Box::new(storage),
438            database_client,
439        }))
440    }
441}
442
443/// Export tool implementation.
444pub struct ExportCreate {
445    config: ExportConfig,
446    storage: Box<dyn SnapshotStorage>,
447    database_client: DatabaseClient,
448}
449
450struct ExportConfig {
451    catalog: String,
452    schemas: Option<Vec<String>>,
453    schema_only: bool,
454    format: DataFormat,
455    packed: bool,
456    force: bool,
457    time_range: TimeRange,
458    chunk_time_window: Option<Duration>,
459    parallelism: usize,
460    chunk_parallelism: usize,
461    progress: ProgressMode,
462    snapshot_uri: String,
463    storage_config: ObjectStoreConfig,
464}
465
466fn parse_chunk_parallelism(value: &str) -> std::result::Result<usize, String> {
467    let parallelism = value
468        .parse::<usize>()
469        .map_err(|_| "chunk parallelism must be an integer between 1 and 64".to_string())?;
470    if (1..=64).contains(&parallelism) {
471        Ok(parallelism)
472    } else {
473        Err("chunk parallelism must be between 1 and 64".to_string())
474    }
475}
476
477#[async_trait]
478impl Tool for ExportCreate {
479    async fn do_work(&self) -> std::result::Result<(), BoxedError> {
480        self.run().await.map_err(BoxedError::new)
481    }
482}
483
484impl ExportCreate {
485    async fn run(&self) -> Result<()> {
486        // 1. Check if snapshot exists
487        let exists = self.storage.exists().await?;
488
489        if exists {
490            if self.config.force {
491                info!("Deleting existing snapshot (--force)");
492                self.storage.delete_snapshot().await?;
493            } else {
494                // Resume mode - read existing manifest
495                let mut manifest = self.storage.read_manifest().await?;
496
497                // Check version compatibility
498                if manifest.validate_layout().is_err() {
499                    return ManifestVersionMismatchSnafu {
500                        expected: MANIFEST_VERSION,
501                        found: manifest.version,
502                    }
503                    .fail();
504                }
505
506                validate_resume_config(&manifest, &self.config)?;
507                if manifest.is_packed() {
508                    require_metric_export_format(manifest.format)?;
509                    self.database_client
510                        .require_packed_export()
511                        .await
512                        .context(DatabaseSnafu)?;
513                }
514
515                info!(
516                    "Resuming existing snapshot: {} (completed: {}/{} chunks)",
517                    manifest.snapshot_id,
518                    manifest.completed_count(),
519                    manifest.chunks.len()
520                );
521
522                if manifest.is_complete() {
523                    info!("Snapshot is already complete");
524                    return Ok(());
525                }
526
527                if manifest.schema_only {
528                    return Ok(());
529                }
530
531                let progress = build_progress_reporter(self.config.progress);
532                export_data(
533                    self.storage.as_ref(),
534                    &self.database_client,
535                    &mut manifest,
536                    ExportDataOptions {
537                        snapshot_uri: &self.config.snapshot_uri,
538                        storage_config: &self.config.storage_config,
539                        parallelism: self.config.parallelism,
540                        chunk_parallelism: self.config.chunk_parallelism,
541                        resume: true,
542                    },
543                    progress.as_ref(),
544                )
545                .await?;
546                return Ok(());
547            }
548        }
549
550        // 2. Get schema list
551        let extractor = SchemaExtractor::new(&self.database_client, &self.config.catalog);
552        let schema_snapshot = extractor.extract(self.config.schemas.as_deref()).await?;
553
554        let schema_names: Vec<String> = schema_snapshot
555            .schemas
556            .iter()
557            .map(|s| s.name.clone())
558            .collect();
559        info!("Exporting schemas: {:?}", schema_names);
560
561        // 3. Create manifest
562        let mut manifest = Manifest::new_for_export(
563            self.config.catalog.clone(),
564            schema_names.clone(),
565            self.config.schema_only,
566            self.config.time_range.clone(),
567            self.config.format,
568            self.config.chunk_time_window,
569        )?;
570
571        if self.config.packed && !self.config.schema_only {
572            manifest.version = 2;
573            manifest.data_layout = Some(common_datasource::packed_snapshot::PACKED_LAYOUT.into());
574        }
575        if manifest.is_packed() {
576            for chunk in &manifest.chunks {
577                self.storage
578                    .prepare_export_chunk(&schema_names, chunk.id, false, manifest.is_packed())
579                    .await?;
580            }
581        }
582
583        // 4. Write schema files
584        self.storage.write_schema(&schema_snapshot).await?;
585        info!("Exported {} schemas", schema_snapshot.schemas.len());
586
587        // 5. Export DDL files for import recovery.
588        let mut sorted_schemas = schema_names;
589        sorted_schemas.sort();
590        for schema in sorted_schemas {
591            let ddl = self.build_schema_ddl(&schema).await?;
592            let ddl_path = ddl_path_for_schema(&schema);
593            self.storage.write_text(&ddl_path, &ddl).await?;
594            info!("Exported DDL for schema {} to {}", schema, ddl_path);
595        }
596
597        // 6. Write manifest after schema artifacts and before any data export.
598        //
599        // The manifest is the snapshot commit point: only write it after the schema
600        // index and all DDL files are durable, so a crash cannot leave a "valid"
601        // snapshot that is missing required schema artifacts. For full exports we
602        // still need the manifest before data copy starts, because chunk resume is
603        // tracked by updating this manifest in place.
604        self.storage.write_manifest(&manifest).await?;
605        info!("Snapshot created: {}", manifest.snapshot_id);
606
607        if !self.config.schema_only {
608            let progress = build_progress_reporter(self.config.progress);
609            export_data(
610                self.storage.as_ref(),
611                &self.database_client,
612                &mut manifest,
613                ExportDataOptions {
614                    snapshot_uri: &self.config.snapshot_uri,
615                    storage_config: &self.config.storage_config,
616                    parallelism: self.config.parallelism,
617                    chunk_parallelism: self.config.chunk_parallelism,
618                    resume: false,
619                },
620                progress.as_ref(),
621            )
622            .await?;
623        }
624
625        Ok(())
626    }
627
628    async fn build_schema_ddl(&self, schema: &str) -> Result<String> {
629        let (mut physical_tables, mut tables, mut views) = self.get_schema_objects(schema).await?;
630        physical_tables.sort();
631        tables.sort();
632        views.sort();
633        let objects = std::iter::once(("DATABASE", None))
634            .chain(
635                physical_tables
636                    .iter()
637                    .chain(&tables)
638                    .map(|table| ("TABLE", Some(table.as_str()))),
639            )
640            .chain(views.iter().map(|view| ("VIEW", Some(view.as_str()))));
641        let mut ddl = format!("-- Schema: {schema}\n");
642        append_schema_ddl(
643            &self.database_client,
644            &self.config.catalog,
645            schema,
646            objects,
647            &mut ddl,
648        )
649        .await
650        .context(DatabaseSnafu)?;
651        ddl.push('\n');
652        Ok(ddl)
653    }
654
655    async fn get_schema_objects(
656        &self,
657        schema: &str,
658    ) -> Result<(Vec<String>, Vec<String>, Vec<String>)> {
659        let physical_tables = self.get_metric_physical_tables(schema).await?;
660        let physical_set: HashSet<&str> = physical_tables.iter().map(String::as_str).collect();
661        let sql = format!(
662            "SELECT table_name, table_type FROM information_schema.tables \
663             WHERE table_catalog = '{}' AND table_schema = '{}' \
664             AND (table_type = 'BASE TABLE' OR table_type = 'VIEW')",
665            escape_sql_literal(&self.config.catalog),
666            escape_sql_literal(schema)
667        );
668        let records: Option<Vec<Vec<Value>>> = self
669            .database_client
670            .sql_in_public(&sql)
671            .await
672            .context(DatabaseSnafu)?;
673
674        let mut tables = Vec::new();
675        let mut views = Vec::new();
676        if let Some(rows) = records {
677            for row in rows {
678                let name = match row.first() {
679                    Some(Value::String(name)) => name.clone(),
680                    _ => return UnexpectedValueTypeSnafu.fail(),
681                };
682                let table_type = match row.get(1) {
683                    Some(Value::String(table_type)) => table_type.as_str(),
684                    _ => return UnexpectedValueTypeSnafu.fail(),
685                };
686                if !physical_set.contains(name.as_str()) {
687                    if table_type == "VIEW" {
688                        views.push(name);
689                    } else {
690                        tables.push(name);
691                    }
692                }
693            }
694        }
695
696        Ok((physical_tables, tables, views))
697    }
698
699    async fn get_metric_physical_tables(&self, schema: &str) -> Result<Vec<String>> {
700        let sql = format!(
701            "SELECT DISTINCT table_name FROM information_schema.columns \
702             WHERE table_catalog = '{}' AND table_schema = '{}' AND column_name = '__tsid'",
703            escape_sql_literal(&self.config.catalog),
704            escape_sql_literal(schema)
705        );
706        let records: Option<Vec<Vec<Value>>> = self
707            .database_client
708            .sql_in_public(&sql)
709            .await
710            .context(DatabaseSnafu)?;
711
712        let mut tables = HashSet::new();
713        if let Some(rows) = records {
714            for row in rows {
715                let name = match row.first() {
716                    Some(Value::String(name)) => name.clone(),
717                    _ => return UnexpectedValueTypeSnafu.fail(),
718                };
719                tables.insert(name);
720            }
721        }
722
723        Ok(tables.into_iter().collect())
724    }
725}
726
727fn require_metric_export_format(format: DataFormat) -> Result<()> {
728    if format != DataFormat::Parquet {
729        return MetricExportRequiresParquetSnafu.fail();
730    }
731    Ok(())
732}
733
734fn validate_resume_config(manifest: &Manifest, config: &ExportConfig) -> Result<()> {
735    if config.packed && !manifest.schema_only && !manifest.is_packed() {
736        return ResumeConfigMismatchSnafu {
737            field: "data_layout",
738            existing: "standalone".to_string(),
739            requested: "packed".to_string(),
740        }
741        .fail();
742    }
743    if manifest.schema_only != config.schema_only {
744        return SchemaOnlyModeMismatchSnafu {
745            existing_schema_only: manifest.schema_only,
746            requested_schema_only: config.schema_only,
747        }
748        .fail();
749    }
750
751    if manifest.catalog != config.catalog {
752        return ResumeConfigMismatchSnafu {
753            field: "catalog",
754            existing: manifest.catalog.clone(),
755            requested: config.catalog.clone(),
756        }
757        .fail();
758    }
759
760    // If no schema filter is provided on resume, inherit the existing snapshot
761    // selection instead of reinterpreting the request as "all schemas".
762    if let Some(requested_schemas) = &config.schemas
763        && !schema_selection_matches(&manifest.schemas, requested_schemas)
764    {
765        return ResumeConfigMismatchSnafu {
766            field: "schemas",
767            existing: format_schema_selection(&manifest.schemas),
768            requested: format_schema_selection(requested_schemas),
769        }
770        .fail();
771    }
772
773    if manifest.time_range != config.time_range {
774        return ResumeConfigMismatchSnafu {
775            field: "time_range",
776            existing: format!("{:?}", manifest.time_range),
777            requested: format!("{:?}", config.time_range),
778        }
779        .fail();
780    }
781
782    if manifest.format != config.format {
783        return ResumeConfigMismatchSnafu {
784            field: "format",
785            existing: manifest.format.to_string(),
786            requested: config.format.to_string(),
787        }
788        .fail();
789    }
790
791    let expected_plan = Manifest::new_for_export(
792        manifest.catalog.clone(),
793        manifest.schemas.clone(),
794        config.schema_only,
795        config.time_range.clone(),
796        config.format,
797        config.chunk_time_window,
798    )?;
799    if !chunk_plan_matches(manifest, &expected_plan) {
800        return ResumeConfigMismatchSnafu {
801            field: "chunk plan",
802            existing: format_chunk_plan(&manifest.chunks),
803            requested: format_chunk_plan(&expected_plan.chunks),
804        }
805        .fail();
806    }
807
808    Ok(())
809}
810
811fn schema_selection_matches(existing: &[String], requested: &[String]) -> bool {
812    canonical_schema_selection(existing) == canonical_schema_selection(requested)
813}
814
815fn canonical_schema_selection(schemas: &[String]) -> Vec<String> {
816    let mut canonicalized = Vec::new();
817    let mut seen = HashSet::new();
818
819    for schema in schemas {
820        let normalized = schema.to_ascii_lowercase();
821        if seen.insert(normalized.clone()) {
822            canonicalized.push(normalized);
823        }
824    }
825
826    canonicalized.sort();
827    canonicalized
828}
829
830fn format_schema_selection(schemas: &[String]) -> String {
831    format!("[{}]", schemas.join(", "))
832}
833
834fn chunk_plan_matches(existing: &Manifest, expected: &Manifest) -> bool {
835    existing.chunks.len() == expected.chunks.len()
836        && existing
837            .chunks
838            .iter()
839            .zip(&expected.chunks)
840            .all(|(left, right)| left.id == right.id && left.time_range == right.time_range)
841}
842
843fn format_chunk_plan(chunks: &[ChunkMeta]) -> String {
844    let items = chunks
845        .iter()
846        .map(|chunk| format!("#{}:{:?}", chunk.id, chunk.time_range))
847        .collect::<Vec<_>>();
848    format!("[{}]", items.join(", "))
849}
850
851#[derive(Debug)]
852struct SnapshotListEntry {
853    path: String,
854    manifest: Manifest,
855}
856
857#[derive(Debug, Default)]
858struct SnapshotScanResult {
859    snapshots: Vec<SnapshotListEntry>,
860    unreadable: Vec<String>,
861}
862
863async fn scan_snapshots(storage: &OpenDalStorage) -> Result<SnapshotScanResult> {
864    let mut result = SnapshotScanResult::default();
865    for dir in storage.list_direct_child_dirs().await? {
866        let manifest_path = format!("{}/{}", dir.trim_matches('/'), MANIFEST_FILE);
867        let Some(data) = storage.read_file_if_exists(&manifest_path).await? else {
868            continue;
869        };
870
871        match serde_json::from_slice::<Manifest>(&data) {
872            Ok(manifest) => result.snapshots.push(SnapshotListEntry {
873                path: format!("{}/", dir.trim_matches('/')),
874                manifest,
875            }),
876            Err(_) => result
877                .unreadable
878                .push(format!("{}/", dir.trim_matches('/'))),
879        }
880    }
881
882    result
883        .snapshots
884        .sort_by_key(|entry| std::cmp::Reverse(entry.manifest.created_at));
885    result.unreadable.sort();
886    Ok(result)
887}
888
889fn print_snapshot_list(snapshots: &[SnapshotListEntry], unreadable_count: usize) {
890    if unreadable_count == 0 {
891        println!("Found {} snapshots:", snapshots.len());
892    } else {
893        println!(
894            "Found {} snapshots ({} {} skipped: unreadable manifest):",
895            snapshots.len(),
896            unreadable_count,
897            directory_word(unreadable_count)
898        );
899    }
900    println!();
901    println!(
902        "  {:<24}  {:<36}  {:<19}  {:<9}  {:<7}  {:<6}  Status",
903        "Path", "ID", "Created", "Catalog", "Schemas", "Chunks"
904    );
905    println!(
906        "  {:<24}  {:<36}  {:<19}  {:<9}  {:<7}  {:<6}  {:<10}",
907        "-".repeat(24),
908        "-".repeat(36),
909        "-".repeat(19),
910        "-".repeat(9),
911        "-".repeat(7),
912        "-".repeat(6),
913        "-".repeat(10)
914    );
915    for entry in snapshots {
916        let manifest = &entry.manifest;
917        println!(
918            "  {:<24}  {:<36}  {:<19}  {:<9}  {:<7}  {:<6}  {}",
919            entry.path,
920            manifest.snapshot_id,
921            manifest.created_at.format("%Y-%m-%d %H:%M:%S"),
922            manifest.catalog,
923            manifest.schemas.len(),
924            format_list_chunks(manifest),
925            snapshot_status(manifest)
926        );
927    }
928}
929
930fn print_unreadable_warnings(unreadable: &[String]) {
931    if unreadable.is_empty() {
932        return;
933    }
934
935    println!();
936    println!(
937        "Warning: {} {} had corrupt/unreadable manifest.json:",
938        unreadable.len(),
939        directory_word(unreadable.len())
940    );
941    for path in unreadable {
942        println!("  - {}", path);
943    }
944}
945
946fn directory_word(count: usize) -> &'static str {
947    if count == 1 {
948        "directory"
949    } else {
950        "directories"
951    }
952}
953
954fn snapshot_status(manifest: &Manifest) -> &'static str {
955    if manifest.validate_layout().is_err() {
956        "unsupported"
957    } else if manifest.schema_only {
958        "schema-only"
959    } else if manifest.is_complete() {
960        "complete"
961    } else {
962        "incomplete"
963    }
964}
965
966fn format_list_chunks(manifest: &Manifest) -> String {
967    let total = manifest.chunks.len();
968    if total == 0 {
969        return "0".to_string();
970    }
971
972    format!(
973        "{}/{}",
974        manifest.completed_count() + manifest.skipped_count(),
975        total
976    )
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
980enum VerifySeverity {
981    Error,
982    Warn,
983}
984
985impl VerifySeverity {
986    fn as_str(self) -> &'static str {
987        match self {
988            VerifySeverity::Error => "ERROR",
989            VerifySeverity::Warn => "WARN",
990        }
991    }
992}
993
994#[derive(Debug)]
995struct VerifyProblem {
996    severity: VerifySeverity,
997    message: String,
998}
999
1000#[derive(Debug, Default)]
1001struct VerifyChunkSummary {
1002    total: usize,
1003    completed: usize,
1004    skipped: usize,
1005    pending: usize,
1006    in_progress: usize,
1007    failed: usize,
1008}
1009
1010#[derive(Debug)]
1011struct VerifyReport {
1012    manifest: Manifest,
1013    schema_index_exists: bool,
1014    ddl_file_count: usize,
1015    chunk_summary: VerifyChunkSummary,
1016    data_files_total: usize,
1017    data_files_verified: usize,
1018    problems: Vec<VerifyProblem>,
1019}
1020
1021impl VerifyReport {
1022    fn error_count(&self) -> usize {
1023        self.problems
1024            .iter()
1025            .filter(|problem| problem.severity == VerifySeverity::Error)
1026            .count()
1027    }
1028
1029    fn warning_count(&self) -> usize {
1030        self.problems
1031            .iter()
1032            .filter(|problem| problem.severity == VerifySeverity::Warn)
1033            .count()
1034    }
1035
1036    fn has_problems(&self) -> bool {
1037        !self.problems.is_empty()
1038    }
1039
1040    fn push_error(&mut self, message: impl Into<String>) {
1041        self.problems.push(VerifyProblem {
1042            severity: VerifySeverity::Error,
1043            message: message.into(),
1044        });
1045    }
1046
1047    fn push_warn(&mut self, message: impl Into<String>) {
1048        self.problems.push(VerifyProblem {
1049            severity: VerifySeverity::Warn,
1050            message: message.into(),
1051        });
1052    }
1053}
1054
1055async fn verify_snapshot(storage: &OpenDalStorage) -> Result<VerifyReport> {
1056    let manifest = storage.read_manifest().await?;
1057    let schema_index_path = format!("{}/{}", SCHEMA_DIR, SCHEMAS_FILE);
1058    let ddl_prefix = format!("{}/{}/", SCHEMA_DIR, DDL_DIR);
1059    let schema_index_exists = storage.file_exists(&schema_index_path).await?;
1060    let ddl_files: HashSet<_> = storage
1061        .list_files_recursive(&ddl_prefix)
1062        .await?
1063        .into_iter()
1064        .collect();
1065    let ddl_file_count = ddl_files
1066        .iter()
1067        .filter(|path| path.ends_with(".sql"))
1068        .count();
1069
1070    let mut report = VerifyReport {
1071        manifest,
1072        schema_index_exists,
1073        ddl_file_count,
1074        chunk_summary: VerifyChunkSummary::default(),
1075        data_files_total: 0,
1076        data_files_verified: 0,
1077        problems: Vec::new(),
1078    };
1079
1080    if let Err(reason) = report.manifest.validate_layout() {
1081        report.push_error(reason);
1082    } else if report.manifest.is_packed()
1083        && let Err(error) = crate::data::import_v2::packed::validate_snapshot(
1084            storage,
1085            &report.manifest,
1086            &report.manifest.schemas,
1087        )
1088        .await
1089    {
1090        report.push_error(error.to_string());
1091    }
1092
1093    if !report.schema_index_exists {
1094        report.push_warn(format!("Missing schema index '{}'", schema_index_path));
1095    }
1096
1097    for schema in &report.manifest.schemas {
1098        let ddl_path = ddl_path_for_schema(schema);
1099        if !ddl_files.contains(ddl_path.as_str()) {
1100            report.problems.push(VerifyProblem {
1101                severity: VerifySeverity::Error,
1102                message: format!("Schema '{}': missing DDL file '{}'", schema, ddl_path),
1103            });
1104        }
1105    }
1106
1107    report.chunk_summary = summarize_chunks(&report.manifest);
1108    if report.manifest.schema_only {
1109        let chunk_count = report.manifest.chunks.len();
1110        if chunk_count > 0 {
1111            report.push_error(format!(
1112                "Schema-only snapshot should not contain data chunks (found {})",
1113                chunk_count
1114            ));
1115        }
1116        let mut first_data_file: Option<String> = None;
1117        storage
1118            .for_each_file_recursive("data/", |path| {
1119                let should_update = match &first_data_file {
1120                    Some(current) => path.as_str() < current.as_str(),
1121                    None => true,
1122                };
1123                if should_update {
1124                    first_data_file = Some(path);
1125                }
1126                Ok(())
1127            })
1128            .await?;
1129        if let Some(path) = first_data_file {
1130            report.push_error(format!(
1131                "Schema-only snapshot should not contain data files (found '{}')",
1132                path
1133            ));
1134        }
1135    } else if report.manifest.chunks.is_empty() {
1136        report.push_error("Full snapshot should contain at least one data chunk");
1137    } else {
1138        verify_chunks_and_data_files(storage, &mut report).await?;
1139    }
1140
1141    Ok(report)
1142}
1143
1144fn summarize_chunks(manifest: &Manifest) -> VerifyChunkSummary {
1145    VerifyChunkSummary {
1146        total: manifest.chunks.len(),
1147        completed: manifest.completed_count(),
1148        skipped: manifest.skipped_count(),
1149        pending: manifest.pending_count(),
1150        in_progress: manifest.in_progress_count(),
1151        failed: manifest.failed_count(),
1152    }
1153}
1154
1155/// A data file declared by a completed chunk that is expected to exist in storage.
1156#[derive(Debug)]
1157struct ChunkFile {
1158    chunk_id: u32,
1159    path: String,
1160}
1161
1162/// Expected snapshot contents derived purely from the manifest (no object-store IO).
1163///
1164/// Separating planning from scanning makes it obvious which problems come from
1165/// the manifest alone and which require comparing against actual storage.
1166#[derive(Debug, Default)]
1167struct VerifyPlan {
1168    /// Valid data files declared by completed chunks; each must exist in storage.
1169    files_to_check: Vec<ChunkFile>,
1170    /// All syntactically-safe data paths declared by any chunk, regardless of
1171    /// status. Used as the orphan-detection baseline so a listed-but-invalid
1172    /// file is not also reported as unexpected.
1173    claimed_data_files: HashSet<String>,
1174    /// Total data-file references in completed chunks (valid + invalid).
1175    data_files_total: usize,
1176    /// Problems detectable from the manifest alone.
1177    problems: Vec<VerifyProblem>,
1178}
1179
1180/// Data-file scan result. Claimed files are kept only when they are relevant to
1181/// manifest verification; unexpected files are kept separately for reporting.
1182#[derive(Debug)]
1183struct VerifyDataScan {
1184    existing_claimed_data_files: HashSet<String>,
1185    unexpected_data_files: Vec<String>,
1186}
1187
1188/// Result of reconciling the manifest plan against the storage scan.
1189#[derive(Debug, Default)]
1190struct VerifyOutcome {
1191    data_files_total: usize,
1192    data_files_verified: usize,
1193    problems: Vec<VerifyProblem>,
1194}
1195
1196async fn verify_chunks_and_data_files(
1197    storage: &OpenDalStorage,
1198    report: &mut VerifyReport,
1199) -> Result<()> {
1200    let plan = build_verify_plan(&report.manifest);
1201    let scan = scan_data_files(storage, &plan).await?;
1202    let outcome = reconcile_plan_with_scan(plan, scan);
1203
1204    report.data_files_total = outcome.data_files_total;
1205    report.data_files_verified = outcome.data_files_verified;
1206    report.problems.extend(outcome.problems);
1207
1208    Ok(())
1209}
1210
1211/// Builds the expected-state plan from the manifest. Pure; performs no IO.
1212fn build_verify_plan(manifest: &Manifest) -> VerifyPlan {
1213    let mut plan = VerifyPlan::default();
1214    let mut seen_chunk_ids = HashSet::new();
1215
1216    for chunk in &manifest.chunks {
1217        if !seen_chunk_ids.insert(chunk.id) {
1218            plan.problems.push(VerifyProblem {
1219                severity: VerifySeverity::Error,
1220                message: format!("Chunk {}: duplicate chunk id", chunk.id),
1221            });
1222        }
1223        for file in &chunk.files {
1224            if let Some(path) = safe_manifest_data_file_path(file) {
1225                plan.claimed_data_files.insert(path.to_string());
1226            }
1227        }
1228
1229        match chunk.status {
1230            ChunkStatus::Completed => {
1231                if chunk.files.is_empty() {
1232                    plan.problems.push(VerifyProblem {
1233                        severity: VerifySeverity::Error,
1234                        message: format!("Chunk {}: completed chunk has no data files", chunk.id),
1235                    });
1236                    continue;
1237                }
1238                let allowed_prefixes = manifest
1239                    .schemas
1240                    .iter()
1241                    .map(|schema| data_dir_for_schema_chunk(schema, chunk.id))
1242                    .collect::<Vec<_>>();
1243                for file in &chunk.files {
1244                    plan.data_files_total += 1;
1245                    match valid_manifest_data_file_path(file, &allowed_prefixes) {
1246                        Some(path) => plan.files_to_check.push(ChunkFile {
1247                            chunk_id: chunk.id,
1248                            path: path.to_string(),
1249                        }),
1250                        None => plan.problems.push(VerifyProblem {
1251                            severity: VerifySeverity::Error,
1252                            message: format!(
1253                                "Chunk {}: invalid data file path '{}'",
1254                                chunk.id, file
1255                            ),
1256                        }),
1257                    }
1258                }
1259            }
1260            ChunkStatus::Skipped => {
1261                if !chunk.files.is_empty() {
1262                    plan.problems.push(VerifyProblem {
1263                        severity: VerifySeverity::Error,
1264                        message: format!(
1265                            "Chunk {}: skipped chunk should not list data files",
1266                            chunk.id
1267                        ),
1268                    });
1269                }
1270            }
1271            ChunkStatus::Pending => {
1272                plan.problems.push(VerifyProblem {
1273                    severity: VerifySeverity::Error,
1274                    message: format!("Chunk {}: status is 'pending'", chunk.id),
1275                });
1276            }
1277            ChunkStatus::InProgress => {
1278                plan.problems.push(VerifyProblem {
1279                    severity: VerifySeverity::Error,
1280                    message: format!("Chunk {}: status is 'in_progress'", chunk.id),
1281                });
1282            }
1283            ChunkStatus::Failed => {
1284                let reason = chunk.error.as_deref().unwrap_or("unknown error");
1285                plan.problems.push(VerifyProblem {
1286                    severity: VerifySeverity::Error,
1287                    message: format!("Chunk {}: status is 'failed' (error: {})", chunk.id, reason),
1288                });
1289            }
1290        }
1291    }
1292
1293    plan
1294}
1295
1296/// Streams data files under `data/` and classifies each path against the plan.
1297async fn scan_data_files(storage: &OpenDalStorage, plan: &VerifyPlan) -> Result<VerifyDataScan> {
1298    let mut scan = VerifyDataScan {
1299        existing_claimed_data_files: HashSet::new(),
1300        unexpected_data_files: Vec::new(),
1301    };
1302
1303    storage
1304        .for_each_file_recursive("data/", |path| {
1305            if plan.claimed_data_files.contains(&path) {
1306                scan.existing_claimed_data_files.insert(path);
1307            } else {
1308                scan.unexpected_data_files.push(path);
1309            }
1310            Ok(())
1311        })
1312        .await?;
1313
1314    Ok(scan)
1315}
1316
1317/// Reconciles the manifest plan against the storage scan. Pure; performs no IO.
1318///
1319/// Emits missing-file problems for expected files absent from storage and
1320/// unexpected-file problems for storage files no chunk claims. Unexpected files
1321/// are sorted by path so output is deterministic regardless of listing order.
1322fn reconcile_plan_with_scan(plan: VerifyPlan, mut scan: VerifyDataScan) -> VerifyOutcome {
1323    let mut problems = plan.problems;
1324    let mut data_files_verified = 0;
1325
1326    for file in &plan.files_to_check {
1327        if scan.existing_claimed_data_files.contains(&file.path) {
1328            data_files_verified += 1;
1329        } else {
1330            problems.push(VerifyProblem {
1331                severity: VerifySeverity::Error,
1332                message: format!("Chunk {}: missing file '{}'", file.chunk_id, file.path),
1333            });
1334        }
1335    }
1336
1337    scan.unexpected_data_files.sort();
1338    for path in scan.unexpected_data_files {
1339        problems.push(VerifyProblem {
1340            severity: VerifySeverity::Error,
1341            message: format!("Unexpected data file '{}' is not listed in manifest", path),
1342        });
1343    }
1344
1345    VerifyOutcome {
1346        data_files_total: plan.data_files_total,
1347        data_files_verified,
1348        problems,
1349    }
1350}
1351
1352fn valid_manifest_data_file_path<'a>(
1353    path: &'a str,
1354    allowed_prefixes: &[String],
1355) -> Option<&'a str> {
1356    let normalized = safe_manifest_data_file_path(path)?;
1357
1358    if !allowed_prefixes
1359        .iter()
1360        .any(|prefix| normalized.starts_with(prefix))
1361    {
1362        return None;
1363    }
1364
1365    Some(normalized)
1366}
1367
1368fn safe_manifest_data_file_path(path: &str) -> Option<&str> {
1369    let normalized = path.trim_start_matches('/');
1370    if normalized.is_empty() || !normalized.starts_with("data/") {
1371        return None;
1372    }
1373
1374    if normalized
1375        .split('/')
1376        .any(|segment| segment.is_empty() || segment == "." || segment == "..")
1377    {
1378        return None;
1379    }
1380
1381    Some(normalized)
1382}
1383
1384fn print_verify_report(snapshot: &str, report: &VerifyReport) {
1385    println!("Verifying snapshot: {}", report.manifest.snapshot_id);
1386    println!("  Location:     {}", snapshot);
1387    if report.manifest.validate_layout().is_ok() {
1388        println!("  Manifest:     OK (version {})", report.manifest.version);
1389    } else {
1390        println!(
1391            "  Manifest:     ERROR (version {}, expected {})",
1392            report.manifest.version, MANIFEST_VERSION
1393        );
1394    }
1395    println!(
1396        "  Schema files: {}",
1397        if report.schema_index_exists {
1398            format!("OK ({})", SCHEMAS_FILE)
1399        } else {
1400            format!("WARN (missing {})", SCHEMAS_FILE)
1401        }
1402    );
1403    if report.ddl_file_count > 0 {
1404        println!("  DDL files:    {} file(s) found", report.ddl_file_count);
1405    } else {
1406        println!("  DDL files:    not present");
1407    }
1408
1409    let chunks = &report.chunk_summary;
1410    println!(
1411        "  Chunks:       {} total ({} completed, {} skipped, {} pending, {} in_progress, {} failed)",
1412        chunks.total,
1413        chunks.completed,
1414        chunks.skipped,
1415        chunks.pending,
1416        chunks.in_progress,
1417        chunks.failed
1418    );
1419
1420    if report.manifest.schema_only {
1421        println!("  Data files:   skipped (schema-only)");
1422    } else {
1423        println!(
1424            "  Data files:   {}/{} files verified",
1425            report.data_files_verified, report.data_files_total
1426        );
1427    }
1428
1429    if report.problems.is_empty() {
1430        println!();
1431        println!("Snapshot is valid.");
1432        return;
1433    }
1434
1435    println!();
1436    println!("Problems found:");
1437    for problem in &report.problems {
1438        println!("  [{}] {}", problem.severity.as_str(), problem.message);
1439    }
1440    println!();
1441    println!(
1442        "Snapshot has {} error(s), {} warning(s).",
1443        report.error_count(),
1444        report.warning_count()
1445    );
1446}
1447
1448fn print_delete_summary(snapshot: &str, manifest: &Manifest) {
1449    println!("Snapshot: {}", manifest.snapshot_id);
1450    println!("  Location: {}", snapshot);
1451    println!(
1452        "  Created:  {} UTC",
1453        manifest.created_at.format("%Y-%m-%d %H:%M:%S")
1454    );
1455    println!("  Catalog:  {}", manifest.catalog);
1456    println!("  Schemas:  {}", manifest.schemas.join(", "));
1457    println!("  Chunks:   {}", format_delete_chunks(manifest));
1458}
1459
1460fn format_delete_chunks(manifest: &Manifest) -> String {
1461    if manifest.schema_only {
1462        return "0 (schema-only)".to_string();
1463    }
1464
1465    let summary = summarize_chunks(manifest);
1466    if manifest.is_complete() {
1467        format!("{} (all processed)", summary.total)
1468    } else {
1469        format!(
1470            "{} ({} completed, {} skipped, {} pending, {} in_progress, {} failed)",
1471            summary.total,
1472            summary.completed,
1473            summary.skipped,
1474            summary.pending,
1475            summary.in_progress,
1476            summary.failed
1477        )
1478    }
1479}
1480
1481fn confirm_delete(snapshot: &str) -> Result<bool> {
1482    println!();
1483    println!(
1484        "Warning: this removes the entire snapshot directory/prefix, not only files listed in manifest."
1485    );
1486    println!("This will permanently delete all data under:");
1487    println!("  {}", display_snapshot_prefix(snapshot));
1488    print!("Type 'yes' to confirm deletion: ");
1489    io::stdout().flush().map_err(|error| {
1490        IoSnafu {
1491            operation: "flushing delete confirmation prompt",
1492            error,
1493        }
1494        .build()
1495    })?;
1496
1497    let mut input = String::new();
1498    io::stdin().read_line(&mut input).map_err(|error| {
1499        IoSnafu {
1500            operation: "reading delete confirmation",
1501            error,
1502        }
1503        .build()
1504    })?;
1505
1506    Ok(delete_confirmation_matches(&input))
1507}
1508
1509fn delete_confirmation_matches(input: &str) -> bool {
1510    input.trim() == "yes"
1511}
1512
1513fn display_snapshot_prefix(snapshot: &str) -> String {
1514    if snapshot.ends_with('/') {
1515        snapshot.to_string()
1516    } else {
1517        format!("{}/", snapshot)
1518    }
1519}
1520
1521#[cfg(test)]
1522mod tests {
1523    use chrono::TimeZone;
1524    use clap::Parser;
1525    use tempfile::tempdir;
1526    use url::Url;
1527
1528    use super::*;
1529    use crate::data::path::ddl_path_for_schema;
1530
1531    #[test]
1532    fn test_ddl_path_for_schema() {
1533        assert_eq!(ddl_path_for_schema("public"), "schema/ddl/public.sql");
1534        assert_eq!(
1535            ddl_path_for_schema("../evil"),
1536            "schema/ddl/%2E%2E%2Fevil.sql"
1537        );
1538    }
1539
1540    #[tokio::test]
1541    async fn test_build_rejects_chunk_window_without_bounds() {
1542        let cmd = ExportCreateCommand::parse_from([
1543            "export-v2-create",
1544            "--addr",
1545            "127.0.0.1:4000",
1546            "--to",
1547            "file:///tmp/export-v2-test",
1548            "--chunk-time-window",
1549            "1h",
1550        ]);
1551
1552        let result = cmd.build().await;
1553        assert!(result.is_err());
1554        let error = result.err().unwrap().to_string();
1555
1556        assert!(error.contains("chunk_time_window requires both --start-time and --end-time"));
1557    }
1558
1559    #[tokio::test]
1560    async fn test_build_rejects_data_export_args_in_schema_only_mode() {
1561        let cmd = ExportCreateCommand::parse_from([
1562            "export-v2-create",
1563            "--addr",
1564            "127.0.0.1:4000",
1565            "--to",
1566            "file:///tmp/export-v2-test",
1567            "--schema-only",
1568            "--start-time",
1569            "2024-01-01T00:00:00Z",
1570            "--end-time",
1571            "2024-01-02T00:00:00Z",
1572            "--chunk-time-window",
1573            "1h",
1574            "--format",
1575            "csv",
1576            "--parallelism",
1577            "2",
1578            "--chunk-parallelism",
1579            "2",
1580        ]);
1581
1582        let error = cmd.build().await.err().unwrap().to_string();
1583
1584        assert!(error.contains("--schema-only cannot be used with data export arguments"));
1585        assert!(error.contains("--start-time"));
1586        assert!(error.contains("--end-time"));
1587        assert!(error.contains("--chunk-time-window"));
1588        assert!(error.contains("--format"));
1589        assert!(error.contains("--parallelism"));
1590        assert!(error.contains("--chunk-parallelism"));
1591    }
1592
1593    #[test]
1594    fn test_chunk_parallelism_defaults_to_one() {
1595        let cmd = ExportCreateCommand::parse_from([
1596            "export-v2-create",
1597            "--addr",
1598            "127.0.0.1:4000",
1599            "--to",
1600            "file:///tmp/export-v2-test",
1601        ]);
1602
1603        assert_eq!(1, cmd.chunk_parallelism);
1604    }
1605
1606    #[test]
1607    fn test_progress_mode_defaults_to_auto() {
1608        let cmd = ExportCreateCommand::parse_from([
1609            "export-v2-create",
1610            "--addr",
1611            "127.0.0.1:4000",
1612            "--to",
1613            "file:///tmp/export-v2-test",
1614        ]);
1615
1616        assert_eq!(ProgressMode::Auto, cmd.progress);
1617    }
1618
1619    #[test]
1620    fn test_progress_mode_parses_explicit_values() {
1621        for (value, expected) in [
1622            ("auto", ProgressMode::Auto),
1623            ("always", ProgressMode::Always),
1624            ("never", ProgressMode::Never),
1625        ] {
1626            let cmd = ExportCreateCommand::parse_from([
1627                "export-v2-create",
1628                "--addr",
1629                "127.0.0.1:4000",
1630                "--to",
1631                "file:///tmp/export-v2-test",
1632                "--progress",
1633                value,
1634            ]);
1635
1636            assert_eq!(expected, cmd.progress);
1637        }
1638    }
1639
1640    #[test]
1641    fn test_progress_mode_rejects_unknown_value() {
1642        assert!(
1643            ExportCreateCommand::try_parse_from([
1644                "export-v2-create",
1645                "--addr",
1646                "127.0.0.1:4000",
1647                "--to",
1648                "file:///tmp/export-v2-test",
1649                "--progress",
1650                "bogus",
1651            ])
1652            .is_err()
1653        );
1654    }
1655
1656    #[tokio::test]
1657    async fn test_pack_metric_data_arguments() {
1658        let directory = tempfile::tempdir().unwrap();
1659        let snapshot = directory.path().join("snapshot");
1660        let uri = url::Url::from_file_path(&snapshot).unwrap();
1661        let args = [
1662            "export-v2-create",
1663            "--addr",
1664            "127.0.0.1:1",
1665            "--to",
1666            uri.as_str(),
1667        ];
1668        assert!(!ExportCreateCommand::parse_from(args).pack_metric_data);
1669        let packed =
1670            ExportCreateCommand::parse_from(args.into_iter().chain(["--pack-metric-data"]));
1671        assert!(packed.pack_metric_data);
1672        for removed in [
1673            &["--experimental-metric-export"][..],
1674            &["--metric-data-layout", "packed"][..],
1675        ] {
1676            let error = ExportCreateCommand::try_parse_from(
1677                args.into_iter().chain(removed.iter().copied()),
1678            )
1679            .unwrap_err();
1680            assert_eq!(error.kind(), clap::error::ErrorKind::UnknownArgument);
1681        }
1682        for format in ["csv", "json"] {
1683            let command = ExportCreateCommand::parse_from(args.into_iter().chain([
1684                "--pack-metric-data",
1685                "--format",
1686                format,
1687            ]));
1688            let error = command.build().await.err().unwrap();
1689            assert!(error.to_string().contains("Metric export requires Parquet"));
1690            assert!(!snapshot.exists());
1691        }
1692    }
1693
1694    #[test]
1695    fn test_chunk_parallelism_parses_valid_value() {
1696        let cmd = ExportCreateCommand::parse_from([
1697            "export-v2-create",
1698            "--addr",
1699            "127.0.0.1:4000",
1700            "--to",
1701            "file:///tmp/export-v2-test",
1702            "--chunk-parallelism",
1703            "64",
1704        ]);
1705
1706        assert_eq!(64, cmd.chunk_parallelism);
1707    }
1708
1709    #[test]
1710    fn test_chunk_parallelism_rejects_out_of_range_values() {
1711        assert!(
1712            ExportCreateCommand::try_parse_from([
1713                "export-v2-create",
1714                "--addr",
1715                "127.0.0.1:4000",
1716                "--to",
1717                "file:///tmp/export-v2-test",
1718                "--chunk-parallelism",
1719                "0",
1720            ])
1721            .is_err()
1722        );
1723        assert!(
1724            ExportCreateCommand::try_parse_from([
1725                "export-v2-create",
1726                "--addr",
1727                "127.0.0.1:4000",
1728                "--to",
1729                "file:///tmp/export-v2-test",
1730                "--chunk-parallelism",
1731                "65",
1732            ])
1733            .is_err()
1734        );
1735    }
1736
1737    #[test]
1738    fn test_schema_only_mode_mismatch_error_message() {
1739        let error = SchemaOnlyModeMismatchSnafu {
1740            existing_schema_only: false,
1741            requested_schema_only: true,
1742        }
1743        .build()
1744        .to_string();
1745
1746        assert!(error.contains("existing: false"));
1747        assert!(error.contains("requested: true"));
1748    }
1749
1750    #[test]
1751    fn test_validate_resume_config_rejects_catalog_mismatch() {
1752        let manifest = Manifest::new_for_export(
1753            "greptime".to_string(),
1754            vec!["public".to_string()],
1755            false,
1756            TimeRange::unbounded(),
1757            DataFormat::Parquet,
1758            None,
1759        )
1760        .unwrap();
1761        let config = ExportConfig {
1762            catalog: "other".to_string(),
1763            schemas: None,
1764            schema_only: false,
1765            format: DataFormat::Parquet,
1766            packed: false,
1767            force: false,
1768            time_range: TimeRange::unbounded(),
1769            chunk_time_window: None,
1770            parallelism: 1,
1771            chunk_parallelism: 1,
1772            progress: ProgressMode::Auto,
1773            snapshot_uri: "file:///tmp/snapshot".to_string(),
1774            storage_config: ObjectStoreConfig::default(),
1775        };
1776
1777        let error = validate_resume_config(&manifest, &config)
1778            .err()
1779            .unwrap()
1780            .to_string();
1781        assert!(error.contains("catalog"));
1782    }
1783
1784    #[test]
1785    fn test_validate_resume_config_accepts_schema_selection_with_different_case_and_order() {
1786        let mut manifest = Manifest::new_for_export(
1787            "greptime".to_string(),
1788            vec!["public".to_string(), "analytics".to_string()],
1789            false,
1790            TimeRange::unbounded(),
1791            DataFormat::Parquet,
1792            None,
1793        )
1794        .unwrap();
1795        let mut config = ExportConfig {
1796            catalog: "greptime".to_string(),
1797            schemas: Some(vec![
1798                "ANALYTICS".to_string(),
1799                "PUBLIC".to_string(),
1800                "public".to_string(),
1801            ]),
1802            schema_only: false,
1803            format: DataFormat::Parquet,
1804            packed: false,
1805            force: false,
1806            time_range: TimeRange::unbounded(),
1807            chunk_time_window: None,
1808            parallelism: 1,
1809            chunk_parallelism: 1,
1810            progress: ProgressMode::Auto,
1811            snapshot_uri: "file:///tmp/snapshot".to_string(),
1812            storage_config: ObjectStoreConfig::default(),
1813        };
1814
1815        assert!(validate_resume_config(&manifest, &config).is_ok());
1816        config.packed = true;
1817        assert!(
1818            validate_resume_config(&manifest, &config)
1819                .unwrap_err()
1820                .to_string()
1821                .contains("data_layout")
1822        );
1823        manifest.version = 2;
1824        manifest.data_layout = Some(common_datasource::packed_snapshot::PACKED_LAYOUT.into());
1825        assert!(validate_resume_config(&manifest, &config).is_ok());
1826        config.packed = false;
1827        assert!(validate_resume_config(&manifest, &config).is_ok());
1828    }
1829
1830    #[test]
1831    fn test_validate_resume_config_rejects_chunk_plan_mismatch() {
1832        let start = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
1833        let end = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 2, 0, 0).unwrap();
1834        let time_range = TimeRange::new(Some(start), Some(end));
1835        let manifest = Manifest::new_for_export(
1836            "greptime".to_string(),
1837            vec!["public".to_string()],
1838            false,
1839            time_range.clone(),
1840            DataFormat::Parquet,
1841            None,
1842        )
1843        .unwrap();
1844        let config = ExportConfig {
1845            catalog: "greptime".to_string(),
1846            schemas: None,
1847            schema_only: false,
1848            format: DataFormat::Parquet,
1849            packed: false,
1850            force: false,
1851            time_range,
1852            chunk_time_window: Some(Duration::from_secs(3600)),
1853            parallelism: 1,
1854            chunk_parallelism: 1,
1855            progress: ProgressMode::Auto,
1856            snapshot_uri: "file:///tmp/snapshot".to_string(),
1857            storage_config: ObjectStoreConfig::default(),
1858        };
1859
1860        let error = validate_resume_config(&manifest, &config)
1861            .err()
1862            .unwrap()
1863            .to_string();
1864        assert!(error.contains("chunk plan"));
1865    }
1866
1867    #[test]
1868    fn test_validate_resume_config_rejects_format_mismatch() {
1869        let manifest = Manifest::new_for_export(
1870            "greptime".to_string(),
1871            vec!["public".to_string()],
1872            false,
1873            TimeRange::unbounded(),
1874            DataFormat::Parquet,
1875            None,
1876        )
1877        .unwrap();
1878        let config = ExportConfig {
1879            catalog: "greptime".to_string(),
1880            schemas: None,
1881            schema_only: false,
1882            format: DataFormat::Csv,
1883            packed: false,
1884            force: false,
1885            time_range: TimeRange::unbounded(),
1886            chunk_time_window: None,
1887            parallelism: 1,
1888            chunk_parallelism: 1,
1889            progress: ProgressMode::Auto,
1890            snapshot_uri: "file:///tmp/snapshot".to_string(),
1891            storage_config: ObjectStoreConfig::default(),
1892        };
1893
1894        let error = validate_resume_config(&manifest, &config)
1895            .err()
1896            .unwrap()
1897            .to_string();
1898        assert!(error.contains("format"));
1899    }
1900
1901    #[test]
1902    fn test_validate_resume_config_rejects_time_range_mismatch() {
1903        let start = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap();
1904        let end = chrono::Utc.with_ymd_and_hms(2025, 1, 1, 1, 0, 0).unwrap();
1905        let manifest = Manifest::new_for_export(
1906            "greptime".to_string(),
1907            vec!["public".to_string()],
1908            false,
1909            TimeRange::new(Some(start), Some(end)),
1910            DataFormat::Parquet,
1911            None,
1912        )
1913        .unwrap();
1914        let config = ExportConfig {
1915            catalog: "greptime".to_string(),
1916            schemas: None,
1917            schema_only: false,
1918            format: DataFormat::Parquet,
1919            packed: false,
1920            force: false,
1921            time_range: TimeRange::new(Some(start), Some(start)),
1922            chunk_time_window: None,
1923            parallelism: 1,
1924            chunk_parallelism: 1,
1925            progress: ProgressMode::Auto,
1926            snapshot_uri: "file:///tmp/snapshot".to_string(),
1927            storage_config: ObjectStoreConfig::default(),
1928        };
1929
1930        let error = validate_resume_config(&manifest, &config)
1931            .err()
1932            .unwrap()
1933            .to_string();
1934        assert!(error.contains("time_range"));
1935    }
1936
1937    #[tokio::test]
1938    async fn test_scan_snapshots_sorts_and_tracks_unreadable_manifests() {
1939        let dir = tempdir().unwrap();
1940        write_test_manifest(
1941            dir.path(),
1942            "older",
1943            test_manifest(
1944                chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1945                false,
1946                true,
1947            ),
1948        );
1949        write_test_manifest(
1950            dir.path(),
1951            "newer",
1952            test_manifest(
1953                chrono::Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap(),
1954                false,
1955                true,
1956            ),
1957        );
1958
1959        std::fs::create_dir_all(dir.path().join("empty-dir")).unwrap();
1960        std::fs::create_dir_all(dir.path().join("not-snapshot")).unwrap();
1961        std::fs::write(dir.path().join("not-snapshot").join("data.txt"), "x").unwrap();
1962        std::fs::create_dir_all(dir.path().join("broken")).unwrap();
1963        std::fs::write(dir.path().join("broken").join(MANIFEST_FILE), "{not-json").unwrap();
1964
1965        let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
1966        let storage = OpenDalStorage::from_file_uri(&uri).unwrap();
1967        let result = scan_snapshots(&storage).await.unwrap();
1968
1969        assert_eq!(result.snapshots.len(), 2);
1970        assert_eq!(
1971            result.snapshots[0].manifest.created_at,
1972            chrono::Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap()
1973        );
1974        assert_eq!(
1975            result.snapshots[1].manifest.created_at,
1976            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()
1977        );
1978        assert_eq!(result.unreadable, vec!["broken/".to_string()]);
1979        assert_eq!(result.snapshots[0].path, "newer/");
1980        assert_eq!(result.snapshots[1].path, "older/");
1981    }
1982
1983    #[test]
1984    fn test_snapshot_list_status_and_chunk_summary() {
1985        let schema_only = test_manifest(
1986            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1987            true,
1988            true,
1989        );
1990        assert_eq!(snapshot_status(&schema_only), "schema-only");
1991        assert_eq!(format_list_chunks(&schema_only), "0");
1992
1993        let complete = test_manifest(
1994            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
1995            false,
1996            true,
1997        );
1998        assert_eq!(snapshot_status(&complete), "complete");
1999        assert_eq!(format_list_chunks(&complete), "2/2");
2000        assert_eq!(format_delete_chunks(&complete), "2 (all processed)");
2001
2002        let incomplete = test_manifest(
2003            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2004            false,
2005            false,
2006        );
2007        assert_eq!(snapshot_status(&incomplete), "incomplete");
2008        assert_eq!(format_list_chunks(&incomplete), "1/2");
2009        assert_eq!(
2010            format_delete_chunks(&incomplete),
2011            "2 (1 completed, 0 skipped, 1 pending, 0 in_progress, 0 failed)"
2012        );
2013    }
2014
2015    #[tokio::test]
2016    async fn test_delete_build_rejects_bucket_root_uri() {
2017        let cmd = ExportDeleteCommand::parse_from([
2018            "export-v2-delete",
2019            "--snapshot",
2020            "s3://bucket",
2021            "--no-confirm",
2022        ]);
2023
2024        let error = cmd.build().await.err().unwrap().to_string();
2025        assert!(error.contains("non-empty path"));
2026    }
2027
2028    #[test]
2029    fn test_delete_skip_confirmation_aliases() {
2030        let no_confirm = ExportDeleteCommand::parse_from([
2031            "export-v2-delete",
2032            "--snapshot",
2033            "s3://bucket/snapshot",
2034            "--no-confirm",
2035        ]);
2036        assert!(no_confirm.skip_confirmation);
2037
2038        let yes = ExportDeleteCommand::parse_from([
2039            "export-v2-delete",
2040            "--snapshot",
2041            "s3://bucket/snapshot",
2042            "--yes",
2043        ]);
2044        assert!(yes.skip_confirmation);
2045    }
2046
2047    #[tokio::test]
2048    async fn test_delete_snapshot_with_no_confirm_removes_snapshot_contents() {
2049        let parent = tempdir().unwrap();
2050        let snapshot = parent.path().join("snapshot");
2051        let sibling = parent.path().join("sibling");
2052        std::fs::create_dir_all(&snapshot).unwrap();
2053        std::fs::create_dir_all(&sibling).unwrap();
2054        std::fs::write(sibling.join("keep.txt"), b"keep").unwrap();
2055        write_root_manifest(
2056            &snapshot,
2057            test_manifest(
2058                chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2059                true,
2060                true,
2061            ),
2062        );
2063        write_snapshot_file(&snapshot, "schema/schemas.json", b"[]");
2064
2065        let uri = Url::from_directory_path(&snapshot).unwrap().to_string();
2066        let delete = ExportDelete {
2067            snapshot: uri,
2068            skip_confirmation: true,
2069            storage: file_storage_for_dir(&snapshot),
2070        };
2071
2072        delete
2073            .run_with_confirmation(|_| unreachable!())
2074            .await
2075            .unwrap();
2076
2077        assert!(!snapshot.join(MANIFEST_FILE).exists());
2078        assert!(!snapshot.join("schema/schemas.json").exists());
2079        assert!(sibling.join("keep.txt").exists());
2080    }
2081
2082    #[tokio::test]
2083    async fn test_delete_snapshot_requires_manifest() {
2084        let dir = tempdir().unwrap();
2085        let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
2086        let delete = ExportDelete {
2087            snapshot: uri,
2088            skip_confirmation: true,
2089            storage: file_storage_for_dir(dir.path()),
2090        };
2091
2092        let error = delete
2093            .run_with_confirmation(|_| unreachable!())
2094            .await
2095            .err()
2096            .unwrap()
2097            .to_string();
2098
2099        assert!(error.contains("Snapshot not found"));
2100        assert!(dir.path().exists());
2101    }
2102
2103    #[tokio::test]
2104    async fn test_delete_snapshot_cancels_without_exact_confirmation() {
2105        let dir = tempdir().unwrap();
2106        write_root_manifest(
2107            dir.path(),
2108            test_manifest(
2109                chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2110                true,
2111                true,
2112            ),
2113        );
2114        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2115        let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
2116        let delete = ExportDelete {
2117            snapshot: uri.clone(),
2118            skip_confirmation: false,
2119            storage: file_storage_for_dir(dir.path()),
2120        };
2121
2122        delete
2123            .run_with_confirmation(|snapshot| {
2124                assert_eq!(snapshot, uri);
2125                Ok(false)
2126            })
2127            .await
2128            .unwrap();
2129
2130        assert!(dir.path().join(MANIFEST_FILE).exists());
2131        assert!(dir.path().join("schema/schemas.json").exists());
2132    }
2133
2134    #[test]
2135    fn test_delete_confirmation_requires_exact_yes() {
2136        assert!(delete_confirmation_matches("yes"));
2137        assert!(delete_confirmation_matches(" yes\n"));
2138        assert!(!delete_confirmation_matches("YES"));
2139        assert!(!delete_confirmation_matches("y"));
2140        assert!(!delete_confirmation_matches("yes please"));
2141    }
2142
2143    #[test]
2144    fn test_display_snapshot_prefix_adds_trailing_slash() {
2145        assert_eq!(
2146            display_snapshot_prefix("s3://bucket/snapshot"),
2147            "s3://bucket/snapshot/"
2148        );
2149        assert_eq!(
2150            display_snapshot_prefix("s3://bucket/snapshot/"),
2151            "s3://bucket/snapshot/"
2152        );
2153    }
2154
2155    #[tokio::test]
2156    async fn test_verify_snapshot_accepts_valid_full_snapshot() {
2157        let dir = tempdir().unwrap();
2158        let manifest = test_manifest(
2159            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2160            false,
2161            true,
2162        );
2163        write_root_manifest(dir.path(), manifest);
2164        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2165        write_default_ddl_files(dir.path());
2166        write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2167
2168        let storage = file_storage_for_dir(dir.path());
2169        let report = verify_snapshot(&storage).await.unwrap();
2170
2171        assert_eq!(report.error_count(), 0);
2172        assert_eq!(report.warning_count(), 0);
2173        assert_eq!(report.data_files_total, 1);
2174        assert_eq!(report.data_files_verified, 1);
2175    }
2176
2177    #[tokio::test]
2178    async fn test_verify_snapshot_reports_missing_data_file_and_failed_chunk() {
2179        let dir = tempdir().unwrap();
2180        let mut manifest = test_manifest(
2181            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2182            false,
2183            true,
2184        );
2185        manifest.chunks[1].mark_failed("copy failed".to_string());
2186        write_root_manifest(dir.path(), manifest);
2187        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2188        write_default_ddl_files(dir.path());
2189
2190        let storage = file_storage_for_dir(dir.path());
2191        let report = verify_snapshot(&storage).await.unwrap();
2192
2193        assert_eq!(report.error_count(), 2);
2194        assert!(
2195            report
2196                .problems
2197                .iter()
2198                .any(|problem| problem.message.contains("missing file"))
2199        );
2200        assert!(
2201            report
2202                .problems
2203                .iter()
2204                .any(|problem| problem.message.contains("status is 'failed'"))
2205        );
2206    }
2207
2208    #[tokio::test]
2209    async fn test_verify_snapshot_reports_missing_schema_index_as_warning() {
2210        let dir = tempdir().unwrap();
2211        let manifest = test_manifest(
2212            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2213            false,
2214            true,
2215        );
2216        write_root_manifest(dir.path(), manifest);
2217        write_default_ddl_files(dir.path());
2218        write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2219
2220        let storage = file_storage_for_dir(dir.path());
2221        let report = verify_snapshot(&storage).await.unwrap();
2222
2223        assert_eq!(report.error_count(), 0);
2224        assert_eq!(report.warning_count(), 1);
2225        assert!(
2226            report
2227                .problems
2228                .iter()
2229                .any(|problem| problem.message.contains("Missing schema index"))
2230        );
2231    }
2232
2233    #[tokio::test]
2234    async fn test_verify_snapshot_rejects_schema_only_snapshot_with_chunks() {
2235        let dir = tempdir().unwrap();
2236        let mut manifest = test_manifest(
2237            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2238            true,
2239            true,
2240        );
2241        let mut chunk = ChunkMeta::new(1, TimeRange::unbounded());
2242        chunk.mark_completed(vec!["data/public/1/file.parquet".to_string()], None);
2243        manifest.chunks.push(chunk);
2244        write_root_manifest(dir.path(), manifest);
2245        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2246        write_default_ddl_files(dir.path());
2247
2248        let storage = file_storage_for_dir(dir.path());
2249        let report = verify_snapshot(&storage).await.unwrap();
2250
2251        assert_eq!(report.error_count(), 1);
2252        assert_eq!(report.data_files_total, 0);
2253        assert!(
2254            report
2255                .problems
2256                .iter()
2257                .any(|problem| problem.message.contains("should not contain data chunks"))
2258        );
2259    }
2260
2261    #[tokio::test]
2262    async fn test_verify_snapshot_rejects_schema_only_snapshot_with_data_files() {
2263        let dir = tempdir().unwrap();
2264        let manifest = test_manifest(
2265            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2266            true,
2267            true,
2268        );
2269        write_root_manifest(dir.path(), manifest);
2270        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2271        write_default_ddl_files(dir.path());
2272        write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2273
2274        let storage = file_storage_for_dir(dir.path());
2275        let report = verify_snapshot(&storage).await.unwrap();
2276
2277        assert_eq!(report.error_count(), 1);
2278        assert_eq!(report.data_files_total, 0);
2279        assert!(
2280            report
2281                .problems
2282                .iter()
2283                .any(|problem| problem.message.contains("should not contain data files"))
2284        );
2285    }
2286
2287    #[tokio::test]
2288    async fn test_verify_snapshot_rejects_full_snapshot_without_chunks() {
2289        let dir = tempdir().unwrap();
2290        let mut manifest = test_manifest(
2291            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2292            false,
2293            true,
2294        );
2295        manifest.chunks.clear();
2296        write_root_manifest(dir.path(), manifest);
2297        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2298        write_default_ddl_files(dir.path());
2299
2300        let storage = file_storage_for_dir(dir.path());
2301        let report = verify_snapshot(&storage).await.unwrap();
2302
2303        assert_eq!(report.error_count(), 1);
2304        assert_eq!(report.data_files_total, 0);
2305        assert!(
2306            report
2307                .problems
2308                .iter()
2309                .any(|problem| problem.message.contains("at least one data chunk"))
2310        );
2311    }
2312
2313    #[tokio::test]
2314    async fn test_verify_snapshot_rejects_skipped_chunk_data_files() {
2315        let dir = tempdir().unwrap();
2316        let manifest = test_manifest(
2317            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2318            false,
2319            true,
2320        );
2321        write_root_manifest(dir.path(), manifest);
2322        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2323        write_default_ddl_files(dir.path());
2324        write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2325        write_snapshot_file(dir.path(), "data/public/2/file.parquet", b"data");
2326
2327        let storage = file_storage_for_dir(dir.path());
2328        let report = verify_snapshot(&storage).await.unwrap();
2329
2330        assert_eq!(report.error_count(), 1);
2331        assert!(
2332            report
2333                .problems
2334                .iter()
2335                .any(|problem| { problem.message.contains("Unexpected data file") })
2336        );
2337    }
2338
2339    #[tokio::test]
2340    async fn test_verify_snapshot_rejects_duplicate_chunk_ids() {
2341        let dir = tempdir().unwrap();
2342        let mut manifest = test_manifest(
2343            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2344            false,
2345            true,
2346        );
2347        let mut duplicate = ChunkMeta::new(1, TimeRange::unbounded());
2348        duplicate.mark_completed(vec!["data/public/1/file.parquet".to_string()], None);
2349        manifest.chunks.push(duplicate);
2350        write_root_manifest(dir.path(), manifest);
2351        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2352        write_default_ddl_files(dir.path());
2353        write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2354
2355        let storage = file_storage_for_dir(dir.path());
2356        let report = verify_snapshot(&storage).await.unwrap();
2357
2358        assert_eq!(report.error_count(), 1);
2359        assert!(
2360            report
2361                .problems
2362                .iter()
2363                .any(|problem| problem.message.contains("duplicate chunk id"))
2364        );
2365    }
2366
2367    #[tokio::test]
2368    async fn test_verify_snapshot_requires_all_schema_ddl() {
2369        let dir = tempdir().unwrap();
2370        let manifest = test_manifest(
2371            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2372            true,
2373            true,
2374        );
2375        write_root_manifest(dir.path(), manifest);
2376        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2377        write_snapshot_file(
2378            dir.path(),
2379            "schema/ddl/public.sql",
2380            b"CREATE DATABASE public;",
2381        );
2382
2383        let storage = file_storage_for_dir(dir.path());
2384        let report = verify_snapshot(&storage).await.unwrap();
2385
2386        assert_eq!(report.error_count(), 1);
2387        assert!(
2388            report
2389                .problems
2390                .iter()
2391                .any(|problem| problem.message.contains("analytics"))
2392        );
2393    }
2394
2395    #[tokio::test]
2396    async fn test_verify_snapshot_reports_missing_ddl_dir() {
2397        let dir = tempdir().unwrap();
2398        let manifest = test_manifest(
2399            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2400            false,
2401            true,
2402        );
2403        write_root_manifest(dir.path(), manifest);
2404        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2405        write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2406
2407        let storage = file_storage_for_dir(dir.path());
2408        let report = verify_snapshot(&storage).await.unwrap();
2409
2410        assert_eq!(report.error_count(), 2);
2411        assert!(
2412            report
2413                .problems
2414                .iter()
2415                .any(|problem| problem.message.contains("schema/ddl/public.sql"))
2416        );
2417        assert!(
2418            report
2419                .problems
2420                .iter()
2421                .any(|problem| problem.message.contains("schema/ddl/analytics.sql"))
2422        );
2423    }
2424
2425    #[tokio::test]
2426    async fn test_verify_snapshot_reports_manifest_version_mismatch() {
2427        let dir = tempdir().unwrap();
2428        let mut manifest = test_manifest(
2429            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2430            false,
2431            true,
2432        );
2433        manifest.version = MANIFEST_VERSION + 1;
2434        write_root_manifest(dir.path(), manifest);
2435        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2436        write_default_ddl_files(dir.path());
2437        write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2438
2439        let storage = file_storage_for_dir(dir.path());
2440        let report = verify_snapshot(&storage).await.unwrap();
2441
2442        assert_eq!(report.error_count(), 1);
2443        assert!(
2444            report
2445                .problems
2446                .iter()
2447                .any(|problem| problem.message.contains("Manifest version mismatch"))
2448        );
2449    }
2450
2451    #[tokio::test]
2452    async fn test_verify_snapshot_rejects_invalid_data_file_paths() {
2453        let dir = tempdir().unwrap();
2454        let mut manifest = test_manifest(
2455            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2456            false,
2457            true,
2458        );
2459        manifest.chunks[0].files = vec!["data/public/1/../file.parquet".to_string()];
2460        write_root_manifest(dir.path(), manifest);
2461        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2462        write_default_ddl_files(dir.path());
2463
2464        let storage = file_storage_for_dir(dir.path());
2465        let report = verify_snapshot(&storage).await.unwrap();
2466
2467        assert_eq!(report.error_count(), 1);
2468        assert!(
2469            report
2470                .problems
2471                .iter()
2472                .any(|problem| problem.message.contains("invalid data file path"))
2473        );
2474        assert_eq!(report.data_files_verified, 0);
2475    }
2476
2477    #[tokio::test]
2478    async fn test_verify_snapshot_accepts_leading_slash_manifest_data_paths() {
2479        let dir = tempdir().unwrap();
2480        let mut manifest = test_manifest(
2481            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2482            false,
2483            true,
2484        );
2485        manifest.chunks[0].files = vec!["/data/public/1/file.parquet".to_string()];
2486        write_root_manifest(dir.path(), manifest);
2487        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2488        write_default_ddl_files(dir.path());
2489        write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2490
2491        let storage = file_storage_for_dir(dir.path());
2492        let report = verify_snapshot(&storage).await.unwrap();
2493
2494        assert_eq!(report.error_count(), 0);
2495        assert_eq!(report.data_files_verified, 1);
2496    }
2497
2498    #[tokio::test]
2499    async fn test_verify_snapshot_rejects_unlisted_files_under_completed_chunk_prefix() {
2500        let dir = tempdir().unwrap();
2501        let manifest = test_manifest(
2502            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2503            false,
2504            true,
2505        );
2506        write_root_manifest(dir.path(), manifest);
2507        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2508        write_default_ddl_files(dir.path());
2509        write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2510        write_snapshot_file(dir.path(), "data/public/1/extra.parquet", b"data");
2511
2512        let storage = file_storage_for_dir(dir.path());
2513        let report = verify_snapshot(&storage).await.unwrap();
2514
2515        assert_eq!(report.error_count(), 1);
2516        assert!(
2517            report
2518                .problems
2519                .iter()
2520                .any(|problem| problem.message.contains("Unexpected data file"))
2521        );
2522        assert_eq!(report.data_files_verified, 1);
2523    }
2524
2525    #[tokio::test]
2526    async fn test_verify_snapshot_rejects_orphan_data_files_outside_known_chunk_prefixes() {
2527        let dir = tempdir().unwrap();
2528        let manifest = test_manifest(
2529            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2530            false,
2531            true,
2532        );
2533        write_root_manifest(dir.path(), manifest);
2534        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2535        write_default_ddl_files(dir.path());
2536        write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2537        write_snapshot_file(dir.path(), "data/public/99/file.parquet", b"data");
2538
2539        let storage = file_storage_for_dir(dir.path());
2540        let report = verify_snapshot(&storage).await.unwrap();
2541
2542        assert_eq!(report.error_count(), 1);
2543        assert!(
2544            report
2545                .problems
2546                .iter()
2547                .any(|problem| problem.message.contains("Unexpected data file"))
2548        );
2549        assert_eq!(report.data_files_verified, 1);
2550    }
2551
2552    #[tokio::test]
2553    async fn test_verify_snapshot_rejects_data_files_under_wrong_chunk_or_schema() {
2554        let dir = tempdir().unwrap();
2555        let mut manifest = test_manifest(
2556            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2557            false,
2558            true,
2559        );
2560        manifest.chunks[0].files = vec![
2561            "data/public/99/file.parquet".to_string(),
2562            "data/metrics/1/file.parquet".to_string(),
2563        ];
2564        write_root_manifest(dir.path(), manifest);
2565        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2566        write_default_ddl_files(dir.path());
2567        write_snapshot_file(dir.path(), "data/public/99/file.parquet", b"data");
2568        write_snapshot_file(dir.path(), "data/metrics/1/file.parquet", b"data");
2569
2570        let storage = file_storage_for_dir(dir.path());
2571        let report = verify_snapshot(&storage).await.unwrap();
2572
2573        assert_eq!(report.error_count(), 2);
2574        assert_eq!(report.data_files_verified, 0);
2575        assert!(
2576            report
2577                .problems
2578                .iter()
2579                .all(|problem| problem.message.contains("invalid data file path"))
2580        );
2581    }
2582
2583    #[test]
2584    fn test_build_verify_plan_classifies_chunks_without_io() {
2585        let mut manifest = test_manifest(
2586            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2587            false,
2588            true,
2589        );
2590        // test_manifest(complete) gives: chunk 1 completed (1 file), chunk 2 skipped.
2591        let mut failed = ChunkMeta::new(3, TimeRange::unbounded());
2592        failed.mark_failed("boom".to_string());
2593        manifest.chunks.push(failed);
2594        manifest
2595            .chunks
2596            .push(ChunkMeta::new(4, TimeRange::unbounded()));
2597
2598        let plan = build_verify_plan(&manifest);
2599
2600        assert_eq!(plan.files_to_check.len(), 1);
2601        assert_eq!(plan.files_to_check[0].chunk_id, 1);
2602        assert_eq!(plan.files_to_check[0].path, "data/public/1/file.parquet");
2603        assert_eq!(plan.data_files_total, 1);
2604        assert!(
2605            plan.claimed_data_files
2606                .contains("data/public/1/file.parquet")
2607        );
2608        assert_eq!(plan.problems.len(), 2);
2609        assert!(
2610            plan.problems
2611                .iter()
2612                .any(|problem| problem.message.contains("status is 'failed'"))
2613        );
2614        assert!(
2615            plan.problems
2616                .iter()
2617                .any(|problem| problem.message.contains("status is 'pending'"))
2618        );
2619    }
2620
2621    #[tokio::test]
2622    async fn test_verify_snapshot_produces_deterministic_problem_output() {
2623        let dir = tempdir().unwrap();
2624        let manifest = test_manifest(
2625            chrono::Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2626            false,
2627            true,
2628        );
2629        write_root_manifest(dir.path(), manifest);
2630        write_snapshot_file(dir.path(), "schema/schemas.json", b"[]");
2631        write_default_ddl_files(dir.path());
2632        write_snapshot_file(dir.path(), "data/public/1/file.parquet", b"data");
2633        // Many orphan files under a known chunk prefix to stress ordering.
2634        for i in 0..50 {
2635            write_snapshot_file(
2636                dir.path(),
2637                &format!("data/public/1/orphan_{:02}.parquet", i),
2638                b"x",
2639            );
2640        }
2641
2642        let storage = file_storage_for_dir(dir.path());
2643        let messages = |report: &VerifyReport| {
2644            report
2645                .problems
2646                .iter()
2647                .map(|problem| problem.message.clone())
2648                .collect::<Vec<_>>()
2649        };
2650        let first = messages(&verify_snapshot(&storage).await.unwrap());
2651        let second = messages(&verify_snapshot(&storage).await.unwrap());
2652
2653        // Output is identical across runs despite HashSet-based scanning.
2654        assert_eq!(first, second);
2655
2656        let orphans = first
2657            .iter()
2658            .filter(|message| message.contains("Unexpected data file"))
2659            .cloned()
2660            .collect::<Vec<_>>();
2661        assert_eq!(orphans.len(), 50);
2662        let mut sorted = orphans.clone();
2663        sorted.sort();
2664        assert_eq!(orphans, sorted);
2665    }
2666
2667    fn write_test_manifest(root: &std::path::Path, dir: &str, manifest: Manifest) {
2668        let snapshot_dir = root.join(dir);
2669        std::fs::create_dir_all(&snapshot_dir).unwrap();
2670        std::fs::write(
2671            snapshot_dir.join(MANIFEST_FILE),
2672            serde_json::to_vec_pretty(&manifest).unwrap(),
2673        )
2674        .unwrap();
2675    }
2676
2677    fn write_root_manifest(root: &std::path::Path, manifest: Manifest) {
2678        std::fs::write(
2679            root.join(MANIFEST_FILE),
2680            serde_json::to_vec_pretty(&manifest).unwrap(),
2681        )
2682        .unwrap();
2683    }
2684
2685    fn write_snapshot_file(root: &std::path::Path, relative_path: &str, content: &[u8]) {
2686        let mut path = root.to_path_buf();
2687        for segment in relative_path.split('/') {
2688            path.push(segment);
2689        }
2690        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2691        std::fs::write(path, content).unwrap();
2692    }
2693
2694    fn write_default_ddl_files(root: &std::path::Path) {
2695        write_snapshot_file(root, "schema/ddl/public.sql", b"CREATE DATABASE public;");
2696        write_snapshot_file(
2697            root,
2698            "schema/ddl/analytics.sql",
2699            b"CREATE DATABASE analytics;",
2700        );
2701    }
2702
2703    fn file_storage_for_dir(root: &std::path::Path) -> OpenDalStorage {
2704        let uri = Url::from_directory_path(root).unwrap().to_string();
2705        OpenDalStorage::from_file_uri(&uri).unwrap()
2706    }
2707
2708    fn test_manifest(
2709        created_at: chrono::DateTime<chrono::Utc>,
2710        schema_only: bool,
2711        complete: bool,
2712    ) -> Manifest {
2713        let mut manifest = Manifest::new_for_export(
2714            "greptime".to_string(),
2715            vec!["public".to_string(), "analytics".to_string()],
2716            schema_only,
2717            TimeRange::unbounded(),
2718            DataFormat::Parquet,
2719            None,
2720        )
2721        .unwrap();
2722        manifest.created_at = created_at;
2723        manifest.updated_at = created_at;
2724
2725        if !schema_only {
2726            manifest.chunks.clear();
2727            let mut first = ChunkMeta::new(1, TimeRange::unbounded());
2728            first.mark_completed(vec!["data/public/1/file.parquet".to_string()], None);
2729            manifest.chunks.push(first);
2730
2731            if complete {
2732                manifest
2733                    .chunks
2734                    .push(ChunkMeta::skipped(2, TimeRange::unbounded()));
2735            } else {
2736                manifest
2737                    .chunks
2738                    .push(ChunkMeta::new(2, TimeRange::unbounded()));
2739            }
2740        }
2741
2742        manifest
2743    }
2744}