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