Skip to main content

cli/data/
export.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
15use std::collections::HashSet;
16use std::sync::Arc;
17use std::time::Duration;
18
19use async_trait::async_trait;
20use clap::{Parser, ValueEnum};
21use common_error::ext::BoxedError;
22use common_telemetry::{debug, error, info};
23use object_store::ObjectStore;
24use serde_json::Value;
25use snafu::{OptionExt, ResultExt};
26use tokio::sync::Semaphore;
27use tokio::time::Instant;
28
29use crate::common::{ObjectStoreConfig, new_fs_object_store};
30use crate::data::schema_export::append_schema_ddl;
31use crate::data::storage_export::{
32    AzblobBackend, FsBackend, GcsBackend, OssBackend, S3Backend, StorageExport, StorageType,
33};
34use crate::data::{COPY_PATH_PLACEHOLDER, default_database};
35use crate::database::{DatabaseClient, parse_proxy_opts};
36use crate::error::{
37    EmptyResultSnafu, Error, OpenDalSnafu, OutputDirNotSetSnafu, Result, SchemaNotFoundSnafu,
38};
39use crate::{Tool, database};
40
41type TableReference = (String, String, String);
42
43#[derive(Debug, Default, Clone, ValueEnum)]
44enum ExportTarget {
45    /// Export all table schemas, corresponding to `SHOW CREATE TABLE`.
46    Schema,
47    /// Export all table data, corresponding to `COPY DATABASE TO`.
48    Data,
49    /// Export all table schemas and data at once.
50    #[default]
51    All,
52}
53
54/// Command for exporting data from the GreptimeDB.
55#[derive(Debug, Default, Parser)]
56pub struct ExportCommand {
57    /// Server address to connect
58    #[clap(long)]
59    addr: String,
60
61    /// Directory to put the exported data. E.g.: /tmp/greptimedb-export
62    /// for local export.
63    #[clap(long)]
64    output_dir: Option<String>,
65
66    /// The name of the catalog to export.
67    #[clap(long, default_value_t = default_database())]
68    database: String,
69
70    /// The number of databases exported in parallel.
71    /// For example, if there are 20 databases and `db_parallelism` is 4,
72    /// 4 databases will be exported concurrently.
73    #[clap(long, short = 'j', default_value = "1", alias = "export-jobs")]
74    db_parallelism: usize,
75
76    /// The number of tables exported in parallel within a single database.
77    /// For example, if a database has 30 tables and `parallelism` is 8,
78    /// 8 tables will be exported concurrently.
79    #[clap(long, default_value = "4")]
80    table_parallelism: usize,
81
82    /// Max retry times for each job.
83    #[clap(long, default_value = "3")]
84    max_retry: usize,
85
86    /// Things to export
87    #[clap(long, short = 't', value_enum, default_value = "all")]
88    target: ExportTarget,
89
90    /// A half-open time range: [start_time, end_time).
91    /// The start of the time range (time-index column) for data export.
92    #[clap(long)]
93    start_time: Option<String>,
94
95    /// A half-open time range: [start_time, end_time).
96    /// The end of the time range (time-index column) for data export.
97    #[clap(long)]
98    end_time: Option<String>,
99
100    /// The basic authentication for connecting to the server
101    #[clap(long)]
102    auth_basic: Option<String>,
103
104    /// The timeout of invoking the database.
105    ///
106    /// It is used to override the server-side timeout setting.
107    /// The default behavior will disable server-side default timeout(i.e. `0s`).
108    #[clap(long, value_parser = humantime::parse_duration)]
109    timeout: Option<Duration>,
110
111    /// The proxy server address to connect.
112    ///
113    /// If set, it overrides the system proxy unless `--no-proxy` is specified.
114    /// If neither `--proxy` nor `--no-proxy` is set, system proxy (env) may be used.
115    #[clap(long)]
116    proxy: Option<String>,
117
118    /// Disable all proxy usage (ignores `--proxy` and system proxy).
119    ///
120    /// When set and `--proxy` is not provided, this explicitly disables system proxy.
121    #[clap(long)]
122    no_proxy: bool,
123
124    /// if both `ddl_local_dir` and remote storage are set, `ddl_local_dir` will be only used for
125    /// exported SQL files, and the data will be exported to remote storage.
126    ///
127    /// Note that `ddl_local_dir` export sql files to **LOCAL** file system, this is useful if export client don't have
128    /// direct access to remote storage.
129    ///
130    /// if remote storage is set but `ddl_local_dir` is not set, both SQL&data will be exported to remote storage.
131    #[clap(long)]
132    ddl_local_dir: Option<String>,
133
134    #[clap(flatten)]
135    storage: ObjectStoreConfig,
136}
137
138impl ExportCommand {
139    pub async fn build(&self) -> std::result::Result<Box<dyn Tool>, BoxedError> {
140        // Determine storage type
141        let (storage_type, operator) = if self.storage.enable_s3 {
142            (
143                StorageType::S3(S3Backend::new(self.storage.s3.clone())?),
144                self.storage.build_s3()?,
145            )
146        } else if self.storage.enable_oss {
147            (
148                StorageType::Oss(OssBackend::new(self.storage.oss.clone())?),
149                self.storage.build_oss()?,
150            )
151        } else if self.storage.enable_gcs {
152            (
153                StorageType::Gcs(GcsBackend::new(self.storage.gcs.clone())?),
154                self.storage.build_gcs()?,
155            )
156        } else if self.storage.enable_azblob {
157            (
158                StorageType::Azblob(AzblobBackend::new(self.storage.azblob.clone())?),
159                self.storage.build_azblob()?,
160            )
161        } else if let Some(output_dir) = &self.output_dir {
162            (
163                StorageType::Fs(FsBackend::new(output_dir.clone())),
164                new_fs_object_store(output_dir)?,
165            )
166        } else {
167            return Err(BoxedError::new(OutputDirNotSetSnafu {}.build()));
168        };
169
170        let (catalog, schema) =
171            database::split_database(&self.database).map_err(BoxedError::new)?;
172        let proxy = parse_proxy_opts(self.proxy.clone(), self.no_proxy)?;
173        let database_client = DatabaseClient::new(
174            self.addr.clone(),
175            catalog.clone(),
176            self.auth_basic.clone(),
177            // Treats `None` as `0s` to disable server-side default timeout.
178            self.timeout.unwrap_or_default(),
179            proxy,
180            self.no_proxy,
181        );
182
183        Ok(Box::new(Export {
184            catalog,
185            schema,
186            database_client,
187            export_jobs: self.db_parallelism,
188            target: self.target.clone(),
189            start_time: self.start_time.clone(),
190            end_time: self.end_time.clone(),
191            parallelism: self.table_parallelism,
192            storage_type,
193            ddl_local_dir: self.ddl_local_dir.clone(),
194            operator,
195        }))
196    }
197}
198
199#[derive(Clone)]
200pub struct Export {
201    catalog: String,
202    schema: Option<String>,
203    database_client: DatabaseClient,
204    export_jobs: usize,
205    target: ExportTarget,
206    start_time: Option<String>,
207    end_time: Option<String>,
208    parallelism: usize,
209    storage_type: StorageType,
210    ddl_local_dir: Option<String>,
211    operator: ObjectStore,
212}
213
214impl Export {
215    async fn get_db_names(&self) -> Result<Vec<String>> {
216        let db_names = self.all_db_names().await?;
217        let Some(schema) = &self.schema else {
218            return Ok(db_names);
219        };
220
221        // Check if the schema exists
222        db_names
223            .into_iter()
224            .find(|db_name| db_name.to_lowercase() == schema.to_lowercase())
225            .map(|name| vec![name])
226            .context(SchemaNotFoundSnafu {
227                catalog: &self.catalog,
228                schema,
229            })
230    }
231
232    /// Iterate over all db names.
233    async fn all_db_names(&self) -> Result<Vec<String>> {
234        let records = self
235            .database_client
236            .sql_in_public("SHOW DATABASES")
237            .await?
238            .context(EmptyResultSnafu)?;
239        let mut result = Vec::with_capacity(records.len());
240        for value in records {
241            let Value::String(schema) = &value[0] else {
242                unreachable!()
243            };
244            if schema == common_catalog::consts::INFORMATION_SCHEMA_NAME {
245                continue;
246            }
247            if schema == common_catalog::consts::PG_CATALOG_NAME {
248                continue;
249            }
250            result.push(schema.clone());
251        }
252        Ok(result)
253    }
254
255    /// Return a list of [`TableReference`] to be exported.
256    /// Includes all tables under the given `catalog` and `schema`.
257    async fn get_table_list(
258        &self,
259        catalog: &str,
260        schema: &str,
261    ) -> Result<(
262        Vec<TableReference>,
263        Vec<TableReference>,
264        Vec<TableReference>,
265    )> {
266        // Puts all metric table first
267        let sql = format!(
268            "SELECT table_catalog, table_schema, table_name \
269            FROM information_schema.columns \
270            WHERE column_name = '__tsid' \
271                and table_catalog = \'{catalog}\' \
272                and table_schema = \'{schema}\'"
273        );
274        let records = self
275            .database_client
276            .sql_in_public(&sql)
277            .await?
278            .context(EmptyResultSnafu)?;
279        let mut metric_physical_tables = HashSet::with_capacity(records.len());
280        for value in records {
281            let mut t = Vec::with_capacity(3);
282            for v in &value {
283                let Value::String(value) = v else {
284                    unreachable!()
285                };
286                t.push(value);
287            }
288            metric_physical_tables.insert((t[0].clone(), t[1].clone(), t[2].clone()));
289        }
290
291        let sql = format!(
292            "SELECT table_catalog, table_schema, table_name, table_type \
293            FROM information_schema.tables \
294            WHERE (table_type = \'BASE TABLE\' OR table_type = \'VIEW\') \
295                and table_catalog = \'{catalog}\' \
296                and table_schema = \'{schema}\'",
297        );
298        let records = self
299            .database_client
300            .sql_in_public(&sql)
301            .await?
302            .context(EmptyResultSnafu)?;
303
304        debug!("Fetched table/view list: {:?}", records);
305
306        if records.is_empty() {
307            return Ok((vec![], vec![], vec![]));
308        }
309
310        let mut remaining_tables = Vec::with_capacity(records.len());
311        let mut views = Vec::new();
312        for value in records {
313            let mut t = Vec::with_capacity(4);
314            for v in &value {
315                let Value::String(value) = v else {
316                    unreachable!()
317                };
318                t.push(value);
319            }
320            let table = (t[0].clone(), t[1].clone(), t[2].clone());
321            let table_type = t[3].as_str();
322            // Ignores the physical table
323            if !metric_physical_tables.contains(&table) {
324                if table_type == "VIEW" {
325                    views.push(table);
326                } else {
327                    remaining_tables.push(table);
328                }
329            }
330        }
331
332        Ok((
333            metric_physical_tables.into_iter().collect(),
334            remaining_tables,
335            views,
336        ))
337    }
338
339    async fn export_create_database(&self) -> Result<()> {
340        let timer = Instant::now();
341        let db_names = self.get_db_names().await?;
342        let db_count = db_names.len();
343        let operator = self.build_prefer_fs_operator().await?;
344
345        for schema in db_names {
346            let mut create_database = String::new();
347            append_schema_ddl(
348                &self.database_client,
349                &self.catalog,
350                &schema,
351                std::iter::once(("DATABASE", None)),
352                &mut create_database,
353            )
354            .await?;
355
356            let file_path = self.get_file_path(&schema, "create_database.sql");
357            self.write_to_storage(&operator, &file_path, create_database.into_bytes())
358                .await?;
359
360            info!(
361                "Exported {}.{} database creation SQL to {}",
362                self.catalog,
363                schema,
364                self.storage_type.format_output_path(&file_path)
365            );
366        }
367
368        let elapsed = timer.elapsed();
369        info!("Success {db_count} jobs, cost: {elapsed:?}");
370
371        Ok(())
372    }
373
374    async fn export_create_table(&self) -> Result<()> {
375        let timer = Instant::now();
376        let semaphore = Arc::new(Semaphore::new(self.export_jobs));
377        let db_names = self.get_db_names().await?;
378        let db_count = db_names.len();
379        let operator = Arc::new(self.build_prefer_fs_operator().await?);
380        let mut tasks = Vec::with_capacity(db_names.len());
381
382        for schema in db_names {
383            let semaphore_moved = semaphore.clone();
384            let export_self = self.clone();
385            let operator = operator.clone();
386            tasks.push(async move {
387                let _permit = semaphore_moved.acquire().await.unwrap();
388                let (metric_physical_tables, remaining_tables, views) = export_self
389                    .get_table_list(&export_self.catalog, &schema)
390                    .await?;
391
392                // Create directory if needed for file system storage
393                if !export_self.storage_type.is_remote_storage() {
394                    let db_dir = format!("{}/{}/", export_self.catalog, schema);
395                    operator.create_dir(&db_dir).await.context(OpenDalSnafu)?;
396                }
397
398                let file_path = export_self.get_file_path(&schema, "create_tables.sql");
399                let mut content = String::new();
400                let objects = metric_physical_tables
401                    .iter()
402                    .chain(&remaining_tables)
403                    .map(|(_, _, table)| ("TABLE", Some(table.as_str())))
404                    .chain(
405                        views
406                            .iter()
407                            .map(|(_, _, view)| ("VIEW", Some(view.as_str()))),
408                    );
409                append_schema_ddl(
410                    &export_self.database_client,
411                    &export_self.catalog,
412                    &schema,
413                    objects,
414                    &mut content,
415                )
416                .await?;
417
418                // Write to storage
419                export_self
420                    .write_to_storage(&operator, &file_path, content.into_bytes())
421                    .await?;
422
423                info!(
424                    "Finished exporting {}.{schema} with {} table schemas to path: {}",
425                    export_self.catalog,
426                    metric_physical_tables.len() + remaining_tables.len() + views.len(),
427                    export_self.storage_type.format_output_path(&file_path)
428                );
429
430                Ok::<(), Error>(())
431            });
432        }
433
434        for result in futures::future::join_all(tasks).await {
435            result?;
436        }
437        let elapsed = timer.elapsed();
438        info!("Success {db_count}/{db_count} jobs, cost: {elapsed:?}");
439
440        Ok(())
441    }
442
443    async fn build_operator(&self) -> Result<ObjectStore> {
444        Ok(self.operator.clone())
445    }
446
447    /// build operator with preference for file system
448    async fn build_prefer_fs_operator(&self) -> Result<ObjectStore> {
449        if self.storage_type.is_remote_storage()
450            && let Some(ddl_local_dir) = &self.ddl_local_dir
451        {
452            let root = ddl_local_dir.clone();
453            let op = new_fs_object_store(&root).map_err(|e| Error::Other {
454                source: e,
455                location: snafu::location!(),
456            })?;
457            Ok(op)
458        } else {
459            Ok(self.operator.clone())
460        }
461    }
462
463    async fn export_database_data(&self) -> Result<()> {
464        let timer = Instant::now();
465        let semaphore = Arc::new(Semaphore::new(self.export_jobs));
466        let db_names = self.get_db_names().await?;
467        let db_count = db_names.len();
468        let mut tasks = Vec::with_capacity(db_count);
469        let operator = Arc::new(self.build_operator().await?);
470        let fs_first_operator = Arc::new(self.build_prefer_fs_operator().await?);
471        let with_options = build_with_options(&self.start_time, &self.end_time, self.parallelism);
472
473        for schema in db_names {
474            let semaphore_moved = semaphore.clone();
475            let export_self = self.clone();
476            let with_options_clone = with_options.clone();
477            let operator = operator.clone();
478            let fs_first_operator = fs_first_operator.clone();
479
480            tasks.push(async move {
481                let _permit = semaphore_moved.acquire().await.unwrap();
482
483                // Create directory if not using remote storage
484                if !export_self.storage_type.is_remote_storage() {
485                    let db_dir = format!("{}/{}/", export_self.catalog, schema);
486                    operator.create_dir(&db_dir).await.context(OpenDalSnafu)?;
487                }
488
489                let (path, connection_part) = export_self
490                    .storage_type
491                    .get_storage_path(&export_self.catalog, &schema);
492
493                // Execute COPY DATABASE TO command
494                let sql = format!(
495                    r#"COPY DATABASE "{}"."{}" TO '{}' WITH ({}){};"#,
496                    export_self.catalog, schema, path, with_options_clone, connection_part
497                );
498
499                // Log SQL command but mask sensitive information
500                let safe_sql = export_self.storage_type.mask_sensitive_info(&sql);
501                info!("Executing sql: {}", safe_sql);
502
503                export_self.database_client.sql_in_public(&sql).await?;
504                info!(
505                    "Finished exporting {}.{} data to {}",
506                    export_self.catalog, schema, path
507                );
508
509                // Create copy_from.sql file
510                let copy_database_from_sql = {
511                    let command_without_connection = format!(
512                        r#"COPY DATABASE "{}"."{}" FROM '{}' WITH ({});"#,
513                        export_self.catalog, schema, COPY_PATH_PLACEHOLDER, with_options_clone
514                    );
515
516                    if connection_part.is_empty() {
517                        command_without_connection
518                    } else {
519                        let command_with_connection = format!(
520                            r#"COPY DATABASE "{}"."{}" FROM '{}' WITH ({}){};"#,
521                            export_self.catalog, schema, path, with_options_clone, connection_part
522                        );
523
524                        format!(
525                            "-- {}\n{}",
526                            command_with_connection, command_without_connection
527                        )
528                    }
529                };
530
531                let copy_from_path = export_self.get_file_path(&schema, "copy_from.sql");
532                export_self
533                    .write_to_storage(
534                        &fs_first_operator,
535                        &copy_from_path,
536                        copy_database_from_sql.into_bytes(),
537                    )
538                    .await?;
539
540                info!(
541                    "Finished exporting {}.{} copy_from.sql to {}",
542                    export_self.catalog,
543                    schema,
544                    export_self.storage_type.format_output_path(&copy_from_path)
545                );
546
547                Ok::<(), Error>(())
548            });
549        }
550
551        let success = self.execute_tasks(tasks).await;
552        let elapsed = timer.elapsed();
553        info!("Success {success}/{db_count} jobs, costs: {elapsed:?}");
554
555        Ok(())
556    }
557
558    fn get_file_path(&self, schema: &str, file_name: &str) -> String {
559        format!("{}/{}/{}", self.catalog, schema, file_name)
560    }
561
562    async fn write_to_storage(
563        &self,
564        op: &ObjectStore,
565        file_path: &str,
566        content: Vec<u8>,
567    ) -> Result<()> {
568        op.write(file_path, content)
569            .await
570            .context(OpenDalSnafu)
571            .map(|_| ())
572    }
573
574    async fn execute_tasks(
575        &self,
576        tasks: Vec<impl std::future::Future<Output = Result<()>>>,
577    ) -> usize {
578        futures::future::join_all(tasks)
579            .await
580            .into_iter()
581            .filter(|r| match r {
582                Ok(_) => true,
583                Err(e) => {
584                    error!(e; "export job failed");
585                    false
586                }
587            })
588            .count()
589    }
590}
591
592#[async_trait]
593impl Tool for Export {
594    async fn do_work(&self) -> std::result::Result<(), BoxedError> {
595        match self.target {
596            ExportTarget::Schema => {
597                self.export_create_database()
598                    .await
599                    .map_err(BoxedError::new)?;
600                self.export_create_table().await.map_err(BoxedError::new)
601            }
602            ExportTarget::Data => self.export_database_data().await.map_err(BoxedError::new),
603            ExportTarget::All => {
604                self.export_create_database()
605                    .await
606                    .map_err(BoxedError::new)?;
607                self.export_create_table().await.map_err(BoxedError::new)?;
608                self.export_database_data().await.map_err(BoxedError::new)
609            }
610        }
611    }
612}
613
614/// Builds the WITH options string for SQL commands, assuming consistent syntax across S3 and local exports.
615fn build_with_options(
616    start_time: &Option<String>,
617    end_time: &Option<String>,
618    parallelism: usize,
619) -> String {
620    let mut options = vec!["format = 'parquet'".to_string()];
621    if let Some(start) = start_time {
622        options.push(format!("start_time = '{}'", start));
623    }
624    if let Some(end) = end_time {
625        options.push(format!("end_time = '{}'", end));
626    }
627    options.push(format!("parallelism = {}", parallelism));
628    options.join(", ")
629}
630
631#[cfg(test)]
632mod tests {
633    use clap::Parser;
634    use common_test_util::temp_dir::create_temp_dir;
635
636    use super::*;
637
638    const MOCK_AZBLOB_ACCOUNT_KEY_B64: &str = "dGVzdC1rZXk=";
639
640    // ==================== Basic Success Cases ====================
641
642    #[tokio::test]
643    async fn test_export_command_build_with_local_fs() {
644        let temp_dir = create_temp_dir("test_export_local_fs");
645        let output_dir = temp_dir.path().to_str().unwrap();
646
647        let cmd = ExportCommand::parse_from([
648            "export",
649            "--addr",
650            "127.0.0.1:4000",
651            "--output-dir",
652            output_dir,
653        ]);
654
655        let result = cmd.build().await;
656        assert!(result.is_ok());
657    }
658
659    #[tokio::test]
660    async fn test_export_command_build_with_s3_success() {
661        let cmd = ExportCommand::parse_from([
662            "export",
663            "--addr",
664            "127.0.0.1:4000",
665            "--s3",
666            "--s3-bucket",
667            "test-bucket",
668            "--s3-root",
669            "test-root",
670            "--s3-access-key-id",
671            "test-key",
672            "--s3-secret-access-key",
673            "test-secret",
674            // Optional fields
675            "--s3-region",
676            "us-west-2",
677            "--s3-endpoint",
678            "https://s3.amazonaws.com",
679        ]);
680
681        let result = cmd.build().await;
682        assert!(result.is_ok());
683    }
684
685    #[tokio::test]
686    async fn test_export_command_build_with_oss_success() {
687        let cmd = ExportCommand::parse_from([
688            "export",
689            "--addr",
690            "127.0.0.1:4000",
691            "--oss",
692            "--oss-bucket",
693            "test-bucket",
694            "--oss-root",
695            "test-root",
696            "--oss-access-key-id",
697            "test-key-id",
698            "--oss-access-key-secret",
699            "test-secret",
700            "--oss-endpoint",
701            "https://oss.example.com",
702        ]);
703
704        let result = cmd.build().await;
705        assert!(result.is_ok());
706    }
707
708    #[tokio::test]
709    async fn test_export_command_build_with_gcs_success() {
710        let cmd = ExportCommand::parse_from([
711            "export",
712            "--addr",
713            "127.0.0.1:4000",
714            "--gcs",
715            "--gcs-bucket",
716            "test-bucket",
717            "--gcs-root",
718            "test-root",
719            "--gcs-scope",
720            "test-scope",
721            "--gcs-credential",
722            "test-credential-content",
723            "--gcs-endpoint",
724            "https://storage.googleapis.com",
725        ]);
726
727        let result = cmd.build().await;
728        assert!(result.is_ok());
729    }
730
731    #[tokio::test]
732    async fn test_export_command_build_with_gcs_adc_success() {
733        // Test GCS with Application Default Credentials (no explicit credentials provided)
734        let cmd = ExportCommand::parse_from([
735            "export",
736            "--addr",
737            "127.0.0.1:4000",
738            "--gcs",
739            "--gcs-bucket",
740            "test-bucket",
741            "--gcs-root",
742            "test-root",
743            "--gcs-scope",
744            "test-scope",
745            // No credential
746            // No endpoint (optional)
747        ]);
748
749        let result = cmd.build().await;
750        assert!(result.is_ok());
751    }
752
753    #[tokio::test]
754    async fn test_export_command_build_with_azblob_success() {
755        let cmd = ExportCommand::parse_from([
756            "export",
757            "--addr",
758            "127.0.0.1:4000",
759            "--azblob",
760            "--azblob-container",
761            "test-container",
762            "--azblob-root",
763            "test-root",
764            "--azblob-account-name",
765            "test-account",
766            "--azblob-account-key",
767            MOCK_AZBLOB_ACCOUNT_KEY_B64,
768            "--azblob-endpoint",
769            "https://account.blob.core.windows.net",
770        ]);
771
772        let result = cmd.build().await;
773        assert!(result.is_ok());
774    }
775
776    #[tokio::test]
777    async fn test_export_command_build_with_azblob_with_sas_token() {
778        // Test Azure Blob with SAS token
779        let cmd = ExportCommand::parse_from([
780            "export",
781            "--addr",
782            "127.0.0.1:4000",
783            "--azblob",
784            "--azblob-container",
785            "test-container",
786            "--azblob-root",
787            "test-root",
788            "--azblob-account-name",
789            "test-account",
790            "--azblob-account-key",
791            MOCK_AZBLOB_ACCOUNT_KEY_B64,
792            "--azblob-endpoint",
793            "https://account.blob.core.windows.net",
794            "--azblob-sas-token",
795            "test-sas-token",
796        ]);
797
798        let result = cmd.build().await;
799        assert!(result.is_ok());
800    }
801
802    // ==================== Gap 1: Parse-time dependency checks ====================
803
804    #[test]
805    fn test_export_command_build_with_conflict() {
806        // Try to enable both S3 and OSS
807        let result =
808            ExportCommand::try_parse_from(["export", "--addr", "127.0.0.1:4000", "--s3", "--oss"]);
809
810        assert!(result.is_err());
811        let err = result.unwrap_err();
812        // clap error for conflicting arguments
813        assert!(err.kind() == clap::error::ErrorKind::ArgumentConflict);
814    }
815
816    #[tokio::test]
817    async fn test_export_command_build_with_s3_no_enable_flag() {
818        // Test that providing S3 config without --s3 flag fails
819        let result = ExportCommand::try_parse_from([
820            "export",
821            "--addr",
822            "127.0.0.1:4000",
823            // Note: no --s3 flag
824            "--s3-bucket",
825            "test-bucket",
826            "--s3-access-key-id",
827            "test-key",
828            "--output-dir",
829            "/tmp/test",
830        ]);
831
832        assert!(result.is_err());
833        let err = result.unwrap_err();
834        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
835        assert!(err.to_string().contains("--s3"));
836    }
837
838    #[tokio::test]
839    async fn test_export_command_build_with_oss_no_enable_flag() {
840        // Test that providing OSS config without --oss flag fails at parse time
841        let result = ExportCommand::try_parse_from([
842            "export",
843            "--addr",
844            "127.0.0.1:4000",
845            "--oss-bucket",
846            "test-bucket",
847            "--output-dir",
848            "/tmp/test",
849        ]);
850
851        assert!(result.is_err());
852        let err = result.unwrap_err();
853        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
854        assert!(err.to_string().contains("--oss"));
855    }
856
857    #[tokio::test]
858    async fn test_export_command_build_with_gcs_no_enable_flag() {
859        // Test that providing GCS config without --gcs flag fails at parse time
860        let result = ExportCommand::try_parse_from([
861            "export",
862            "--addr",
863            "127.0.0.1:4000",
864            "--gcs-bucket",
865            "test-bucket",
866            "--output-dir",
867            "/tmp/test",
868        ]);
869
870        assert!(result.is_err());
871        let err = result.unwrap_err();
872        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
873        assert!(err.to_string().contains("--gcs"));
874    }
875
876    #[tokio::test]
877    async fn test_export_command_build_with_azblob_no_enable_flag() {
878        // Test that providing Azure Blob config without --azblob flag fails at parse time
879        let result = ExportCommand::try_parse_from([
880            "export",
881            "--addr",
882            "127.0.0.1:4000",
883            "--azblob-container",
884            "test-container",
885            "--output-dir",
886            "/tmp/test",
887        ]);
888
889        assert!(result.is_err());
890        let err = result.unwrap_err();
891        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
892        assert!(err.to_string().contains("--azblob"));
893    }
894
895    // ==================== Gap 2: Empty string vs missing tests ====================
896
897    #[tokio::test]
898    async fn test_export_command_build_with_s3_empty_root() {
899        // Empty root should be allowed (it's optional path component)
900        let cmd = ExportCommand::parse_from([
901            "export",
902            "--addr",
903            "127.0.0.1:4000",
904            "--s3",
905            "--s3-bucket",
906            "test-bucket",
907            "--s3-root",
908            "", // Empty root is OK
909            "--s3-access-key-id",
910            "test-key",
911            "--s3-secret-access-key",
912            "test-secret",
913            "--s3-region",
914            "us-west-2",
915        ]);
916
917        let result = cmd.build().await;
918        // Should succeed because root is not a required field
919        assert!(
920            result.is_ok(),
921            "Expected success but got: {:?}",
922            result.err()
923        );
924    }
925
926    #[tokio::test]
927    async fn test_export_command_build_with_oss_empty_access_key_id() {
928        // Test OSS with empty access_key_id (empty string, not missing)
929        let cmd = ExportCommand::parse_from([
930            "export",
931            "--addr",
932            "127.0.0.1:4000",
933            "--oss",
934            "--oss-bucket",
935            "test-bucket",
936            "--oss-access-key-id",
937            "", // Empty string
938            "--oss-access-key-secret",
939            "test-secret",
940            "--oss-endpoint",
941            "https://oss.example.com",
942        ]);
943
944        let result = cmd.build().await;
945        assert!(result.is_err());
946        if let Err(err) = result {
947            assert!(
948                err.to_string().contains("OSS access key ID must be set"),
949                "Actual error: {}",
950                err
951            );
952        }
953    }
954
955    #[tokio::test]
956    async fn test_export_command_build_with_oss_missing_endpoint() {
957        // Missing endpoint
958        let cmd = ExportCommand::parse_from([
959            "export",
960            "--addr",
961            "127.0.0.1:4000",
962            "--oss",
963            "--oss-bucket",
964            "test-bucket",
965            "--oss-root",
966            "test-root",
967            "--oss-access-key-id",
968            "test-key-id",
969            "--oss-access-key-secret",
970            "test-secret",
971        ]);
972
973        let result = cmd.build().await;
974        assert!(result.is_err());
975        if let Err(err) = result {
976            assert!(
977                err.to_string().contains("OSS endpoint must be set"),
978                "Actual error: {}",
979                err
980            );
981        }
982    }
983
984    #[tokio::test]
985    async fn test_export_command_build_with_oss_multiple_missing_fields() {
986        // Test OSS with multiple missing required fields
987        let cmd = ExportCommand::parse_from([
988            "export",
989            "--addr",
990            "127.0.0.1:4000",
991            "--oss",
992            "--oss-bucket",
993            "test-bucket",
994            // Missing: root, access_key_id, access_key_secret, endpoint
995        ]);
996
997        let result = cmd.build().await;
998        assert!(result.is_err());
999        if let Err(err) = result {
1000            let err_str = err.to_string();
1001            // Should mention multiple missing fields
1002            assert!(
1003                err_str.contains("OSS"),
1004                "Error should mention OSS: {}",
1005                err_str
1006            );
1007            assert!(
1008                err_str.contains("must be set"),
1009                "Error should mention required fields: {}",
1010                err_str
1011            );
1012        }
1013    }
1014
1015    #[tokio::test]
1016    async fn test_export_command_build_with_gcs_empty_bucket() {
1017        // Test GCS with empty bucket
1018        let cmd = ExportCommand::parse_from([
1019            "export",
1020            "--addr",
1021            "127.0.0.1:4000",
1022            "--gcs",
1023            "--gcs-bucket",
1024            "", // Empty bucket
1025            "--gcs-root",
1026            "test-root",
1027            "--gcs-scope",
1028            "test-scope",
1029        ]);
1030
1031        let result = cmd.build().await;
1032        assert!(result.is_err());
1033        if let Err(err) = result {
1034            assert!(
1035                err.to_string().contains("GCS bucket must be set"),
1036                "Actual error: {}",
1037                err
1038            );
1039        }
1040    }
1041
1042    #[tokio::test]
1043    async fn test_export_command_build_with_gcs_empty_root() {
1044        // Test GCS when root is missing (should fail as it's required)
1045        let cmd = ExportCommand::parse_from([
1046            "export",
1047            "--addr",
1048            "127.0.0.1:4000",
1049            "--gcs",
1050            "--gcs-bucket",
1051            "test-bucket",
1052            "--gcs-root",
1053            "", // Empty root
1054            "--gcs-scope",
1055            "test-scope",
1056            "--gcs-credential",
1057            "test-credential",
1058            "--gcs-endpoint",
1059            "https://storage.googleapis.com",
1060        ]);
1061
1062        let result = cmd.build().await;
1063        assert!(result.is_err());
1064        if let Err(err) = result {
1065            assert!(
1066                err.to_string().contains("GCS root must be set"),
1067                "Actual error: {}",
1068                err
1069            );
1070        }
1071    }
1072
1073    #[tokio::test]
1074    async fn test_export_command_build_with_azblob_empty_account_name() {
1075        // account_name is optional for Azure Blob validation
1076        let cmd = ExportCommand::parse_from([
1077            "export",
1078            "--addr",
1079            "127.0.0.1:4000",
1080            "--azblob",
1081            "--azblob-container",
1082            "test-container",
1083            "--azblob-account-name",
1084            "", // Empty account name
1085            "--azblob-endpoint",
1086            "https://account.blob.core.windows.net",
1087        ]);
1088
1089        let result = cmd.build().await;
1090        assert!(result.is_ok(), "Empty account_name should succeed");
1091    }
1092
1093    #[tokio::test]
1094    async fn test_export_command_build_with_azblob_missing_account_key() {
1095        // account_key is optional for Azure Blob validation
1096        let cmd = ExportCommand::parse_from([
1097            "export",
1098            "--addr",
1099            "127.0.0.1:4000",
1100            "--azblob",
1101            "--azblob-container",
1102            "test-container",
1103            "--azblob-endpoint",
1104            "https://account.blob.core.windows.net",
1105        ]);
1106
1107        let result = cmd.build().await;
1108        assert!(result.is_ok(), "Missing account_key should succeed");
1109    }
1110
1111    // ==================== Gap 3: Boundary cases ====================
1112
1113    #[tokio::test]
1114    async fn test_export_command_build_with_no_storage() {
1115        // No output-dir and no backend - should fail
1116        let cmd = ExportCommand::parse_from(["export", "--addr", "127.0.0.1:4000"]);
1117
1118        let result = cmd.build().await;
1119        assert!(result.is_err());
1120        if let Err(err) = result {
1121            assert!(
1122                err.to_string().contains("Output directory not set"),
1123                "Actual error: {}",
1124                err
1125            );
1126        }
1127    }
1128
1129    #[tokio::test]
1130    async fn test_export_command_build_with_s3_minimal_config() {
1131        // S3 with only required fields (no optional fields)
1132        let cmd = ExportCommand::parse_from([
1133            "export",
1134            "--addr",
1135            "127.0.0.1:4000",
1136            "--s3",
1137            "--s3-bucket",
1138            "test-bucket",
1139            "--s3-access-key-id",
1140            "test-key",
1141            "--s3-secret-access-key",
1142            "test-secret",
1143            "--s3-region",
1144            "us-west-2",
1145            // No root, endpoint, or enable_virtual_host_style
1146        ]);
1147
1148        let result = cmd.build().await;
1149        assert!(result.is_ok(), "Minimal S3 config should succeed");
1150    }
1151
1152    #[tokio::test]
1153    async fn test_export_command_build_with_oss_minimal_config() {
1154        // OSS with only required fields
1155        let cmd = ExportCommand::parse_from([
1156            "export",
1157            "--addr",
1158            "127.0.0.1:4000",
1159            "--oss",
1160            "--oss-bucket",
1161            "test-bucket",
1162            "--oss-access-key-id",
1163            "test-key-id",
1164            "--oss-access-key-secret",
1165            "test-secret",
1166            "--oss-endpoint",
1167            "https://oss.example.com",
1168            // No root
1169        ]);
1170
1171        let result = cmd.build().await;
1172        assert!(result.is_ok(), "Minimal OSS config should succeed");
1173    }
1174
1175    #[tokio::test]
1176    async fn test_export_command_build_with_gcs_minimal_config() {
1177        // GCS with only required fields (using ADC)
1178        let cmd = ExportCommand::parse_from([
1179            "export",
1180            "--addr",
1181            "127.0.0.1:4000",
1182            "--gcs",
1183            "--gcs-bucket",
1184            "test-bucket",
1185            "--gcs-root",
1186            "test-root",
1187            "--gcs-scope",
1188            "test-scope",
1189            // No credential, or endpoint
1190        ]);
1191
1192        let result = cmd.build().await;
1193        assert!(result.is_ok(), "Minimal GCS config should succeed");
1194    }
1195
1196    #[tokio::test]
1197    async fn test_export_command_build_with_azblob_minimal_config() {
1198        // Azure Blob with only required fields
1199        let cmd = ExportCommand::parse_from([
1200            "export",
1201            "--addr",
1202            "127.0.0.1:4000",
1203            "--azblob",
1204            "--azblob-container",
1205            "test-container",
1206            "--azblob-endpoint",
1207            "https://account.blob.core.windows.net",
1208        ]);
1209
1210        let result = cmd.build().await;
1211        assert!(result.is_ok(), "Minimal AzBlob config should succeed");
1212    }
1213
1214    #[tokio::test]
1215    async fn test_export_command_build_with_azblob_missing_endpoint() {
1216        let cmd = ExportCommand::parse_from([
1217            "export",
1218            "--addr",
1219            "127.0.0.1:4000",
1220            "--azblob",
1221            "--azblob-container",
1222            "test-container",
1223        ]);
1224
1225        let result = cmd.build().await;
1226        assert!(result.is_err());
1227        if let Err(err) = result {
1228            assert!(
1229                err.to_string().contains("AzBlob endpoint must be set"),
1230                "Actual error: {}",
1231                err
1232            );
1233        }
1234    }
1235
1236    #[tokio::test]
1237    async fn test_export_command_build_with_azblob_missing_container() {
1238        let cmd = ExportCommand::parse_from([
1239            "export",
1240            "--addr",
1241            "127.0.0.1:4000",
1242            "--azblob",
1243            "--azblob-endpoint",
1244            "https://account.blob.core.windows.net",
1245        ]);
1246
1247        let result = cmd.build().await;
1248        assert!(result.is_err());
1249        if let Err(err) = result {
1250            assert!(
1251                err.to_string().contains("AzBlob container must be set"),
1252                "Actual error: {}",
1253                err
1254            );
1255        }
1256    }
1257
1258    #[tokio::test]
1259    async fn test_export_command_build_with_local_and_s3() {
1260        // Both output-dir and S3 - S3 should take precedence
1261        let temp_dir = create_temp_dir("test_export_local_and_s3");
1262        let output_dir = temp_dir.path().to_str().unwrap();
1263
1264        let cmd = ExportCommand::parse_from([
1265            "export",
1266            "--addr",
1267            "127.0.0.1:4000",
1268            "--output-dir",
1269            output_dir,
1270            "--s3",
1271            "--s3-bucket",
1272            "test-bucket",
1273            "--s3-access-key-id",
1274            "test-key",
1275            "--s3-secret-access-key",
1276            "test-secret",
1277            "--s3-region",
1278            "us-west-2",
1279        ]);
1280
1281        let result = cmd.build().await;
1282        assert!(
1283            result.is_ok(),
1284            "S3 should be selected when both are provided"
1285        );
1286    }
1287
1288    // ==================== Gap 4: Custom validation (Azure Blob) ====================
1289
1290    #[tokio::test]
1291    async fn test_export_command_build_with_azblob_only_sas_token() {
1292        // Azure Blob with sas_token but no credentials - should still succeed
1293        let cmd = ExportCommand::parse_from([
1294            "export",
1295            "--addr",
1296            "127.0.0.1:4000",
1297            "--azblob",
1298            "--azblob-container",
1299            "test-container",
1300            "--azblob-endpoint",
1301            "https://account.blob.core.windows.net",
1302            "--azblob-sas-token",
1303            "test-sas-token",
1304        ]);
1305
1306        let result = cmd.build().await;
1307        assert!(
1308            result.is_ok(),
1309            "AzBlob with only sas_token should succeed: {:?}",
1310            result.err()
1311        );
1312    }
1313
1314    #[tokio::test]
1315    async fn test_export_command_build_with_azblob_empty_account_key_with_sas() {
1316        // Azure Blob with empty account_key but valid sas_token - should succeed
1317        let cmd = ExportCommand::parse_from([
1318            "export",
1319            "--addr",
1320            "127.0.0.1:4000",
1321            "--azblob",
1322            "--azblob-container",
1323            "test-container",
1324            "--azblob-account-key",
1325            "", // Empty account_key is OK if sas_token is provided
1326            "--azblob-endpoint",
1327            "https://account.blob.core.windows.net",
1328            "--azblob-sas-token",
1329            "test-sas-token",
1330        ]);
1331
1332        let result = cmd.build().await;
1333        assert!(
1334            result.is_ok(),
1335            "AzBlob with empty account_key but sas_token should succeed: {:?}",
1336            result.err()
1337        );
1338    }
1339}