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