Skip to main content

spawn_db/
cli.rs

1use crate::commands::{
2    AdoptMigration, ApplyMigration, BuildMigration, BuildTest, Check, Command, CompareTests,
3    ExpectTest, Init, MigrationStatus, NewMigration, NewTest, Outcome, PinMigration, RunTest,
4    TelemetryDescribe, TelemetryInfo,
5};
6use crate::config::Config;
7use opendal::Operator;
8
9use anyhow::{anyhow, Result};
10use clap::{Parser, Subcommand};
11
12#[derive(Parser)]
13#[command(version, about, long_about = None)]
14pub struct Cli {
15    /// Turn debugging information on
16    #[arg(short, long)]
17    pub debug: bool,
18
19    #[arg(global = true, short, long, default_value = "spawn.toml")]
20    pub config_file: String,
21
22    #[arg(global = true, long)]
23    pub database: Option<String>,
24
25    /// Internal flag for telemetry child process (hidden)
26    #[arg(long, hide = true)]
27    pub internal_telemetry: bool,
28
29    #[command(subcommand)]
30    pub command: Option<Commands>,
31}
32
33impl TelemetryDescribe for Cli {
34    fn telemetry(&self) -> TelemetryInfo {
35        match &self.command {
36            Some(cmd) => cmd.telemetry(),
37            None => TelemetryInfo::default(),
38        }
39    }
40}
41
42#[derive(Subcommand)]
43pub enum Commands {
44    /// Initialize a new migration environment
45    Init {
46        /// Generate a docker-compose.yaml file for local PostgreSQL development.
47        /// Optionally specify a database/project name (defaults to 'postgres').
48        #[arg(long)]
49        docker: Option<Option<String>>,
50    },
51    /// Check for potential issues (unpinned migrations, etc.)
52    Check,
53    Migration {
54        #[command(subcommand)]
55        command: Option<MigrationCommands>,
56        #[arg(short, long, global = true)]
57        environment: Option<String>,
58    },
59    Test {
60        #[command(subcommand)]
61        command: Option<TestCommands>,
62    },
63}
64
65impl TelemetryDescribe for Commands {
66    fn telemetry(&self) -> TelemetryInfo {
67        match self {
68            Commands::Init { .. } => TelemetryInfo::new("init"),
69            Commands::Check => TelemetryInfo::new("check"),
70            Commands::Migration { command, .. } => match command {
71                Some(cmd) => {
72                    let mut info = cmd.telemetry();
73                    info.label = format!("migration {}", info.label);
74                    info
75                }
76                None => TelemetryInfo::new("migration"),
77            },
78            Commands::Test { command } => match command {
79                Some(cmd) => {
80                    let mut info = cmd.telemetry();
81                    info.label = format!("test {}", info.label);
82                    info
83                }
84                None => TelemetryInfo::new("test"),
85            },
86        }
87    }
88}
89
90#[derive(Subcommand)]
91pub enum MigrationCommands {
92    /// Create a new migration with the provided name
93    New {
94        /// Name of the migration.
95        name: String,
96    },
97    /// Pin a migration with current components
98    Pin {
99        /// Migration to pin
100        migration: String,
101    },
102    /// Build a migration into SQL
103    Build {
104        /// Whether to use pinned components
105        #[arg(long)]
106        pinned: bool,
107        /// Migration to build.  Looks for up.sql inside this specified
108        /// migration folder.
109        migration: String,
110        /// Path to a variables file (JSON, TOML, or YAML) to use for templating.
111        /// Overrides the variables_file setting in spawn.toml.
112        #[arg(long)]
113        variables: Option<String>,
114    },
115    /// Apply will apply this migration to the database if not already applied,
116    /// or all migrations if called without argument.
117    Apply {
118        /// Skip the pin requirement and use unpinned components
119        #[arg(long)]
120        no_pin: bool,
121
122        migration: Option<String>,
123
124        /// Path to a variables file (JSON, TOML, or YAML) to use for templating.
125        /// Overrides the variables_file setting in spawn.toml.
126        #[arg(long)]
127        variables: Option<String>,
128
129        /// Skip confirmation prompt
130        #[arg(long)]
131        yes: bool,
132
133        /// Retry a previously failed migration
134        #[arg(long)]
135        retry: bool,
136    },
137    /// Mark a migration as applied without actually running it.
138    /// Useful when a migration was applied manually and needs to be recorded.
139    Adopt {
140        /// Migration to adopt
141        migration: Option<String>,
142
143        /// Skip confirmation prompt
144        #[arg(long)]
145        yes: bool,
146
147        /// Description of why the migration is being adopted
148        #[arg(long)]
149        description: Option<String>,
150    },
151    /// Show the status of all migrations
152    Status,
153}
154
155impl TelemetryDescribe for MigrationCommands {
156    fn telemetry(&self) -> TelemetryInfo {
157        match self {
158            MigrationCommands::New { .. } => TelemetryInfo::new("new"),
159            MigrationCommands::Pin { .. } => TelemetryInfo::new("pin"),
160            MigrationCommands::Build {
161                pinned, variables, ..
162            } => TelemetryInfo::new("build").with_properties(vec![
163                ("opt_pinned", pinned.to_string()),
164                ("has_variables", variables.is_some().to_string()),
165            ]),
166            MigrationCommands::Apply {
167                no_pin,
168                variables,
169                migration,
170                retry,
171                ..
172            } => TelemetryInfo::new("apply").with_properties(vec![
173                ("opt_no_pin", no_pin.to_string()),
174                ("opt_retry", retry.to_string()),
175                ("has_variables", variables.is_some().to_string()),
176                ("apply_all", migration.is_none().to_string()),
177            ]),
178            MigrationCommands::Adopt { .. } => TelemetryInfo::new("adopt"),
179            MigrationCommands::Status => TelemetryInfo::new("status"),
180        }
181    }
182}
183
184#[derive(Subcommand)]
185pub enum TestCommands {
186    /// Create a new test with the provided name
187    New {
188        /// Name of the test
189        name: String,
190    },
191    Build {
192        name: String,
193    },
194    /// Run a particular test, or all tests if no name provided.
195    Run {
196        name: Option<String>,
197    },
198    /// Run tests and compare to expected.  Runs all tests if no name provided.
199    Compare {
200        name: Option<String>,
201    },
202    Expect {
203        name: String,
204    },
205}
206
207impl TelemetryDescribe for TestCommands {
208    fn telemetry(&self) -> TelemetryInfo {
209        match self {
210            TestCommands::New { .. } => TelemetryInfo::new("new"),
211            TestCommands::Build { .. } => TelemetryInfo::new("build"),
212            TestCommands::Run { name } => TelemetryInfo::new("run")
213                .with_properties(vec![("run_all", name.is_none().to_string())]),
214            TestCommands::Compare { name } => TelemetryInfo::new("compare")
215                .with_properties(vec![("compare_all", name.is_none().to_string())]),
216            TestCommands::Expect { .. } => TelemetryInfo::new("expect"),
217        }
218    }
219}
220
221/// Result of running the CLI, including telemetry information
222pub struct CliResult {
223    pub outcome: Result<Outcome>,
224    /// Project ID from config (for telemetry distinct_id)
225    pub project_id: Option<String>,
226    /// Whether telemetry is enabled in config
227    pub telemetry_enabled: bool,
228}
229
230pub async fn run_cli(cli: Cli, base_op: &Operator) -> CliResult {
231    // Handle init command separately as it doesn't require existing config
232    if let Some(Commands::Init { docker }) = &cli.command {
233        let init_cmd = Init {
234            config_file: cli.config_file.clone(),
235            docker: docker.clone(),
236        };
237        match init_cmd.execute(base_op).await {
238            Ok((outcome, project_id)) => {
239                return CliResult {
240                    outcome: Ok(outcome),
241                    project_id: Some(project_id),
242                    telemetry_enabled: true,
243                };
244            }
245            Err(e) => {
246                return CliResult {
247                    outcome: Err(e),
248                    project_id: None,
249                    telemetry_enabled: true,
250                };
251            }
252        }
253    }
254
255    // Check if config file exists to show telemetry notice
256    let config_exists = base_op.exists(&cli.config_file).await.unwrap_or(false);
257
258    // Load config from file (required for all other commands)
259    let mut main_config = match Config::load(&cli.config_file, base_op, cli.database.clone()).await
260    {
261        Ok(cfg) => cfg,
262        Err(e) => {
263            // If config doesn't exist, show helpful message
264            if !config_exists {
265                crate::show_telemetry_notice();
266                eprintln!("No spawn.toml configuration file found.");
267                eprintln!("Run `spawn init` to create a new spawn project.");
268                return CliResult {
269                    outcome: Err(anyhow!("Configuration file not found")),
270                    project_id: None,
271                    telemetry_enabled: false,
272                };
273            }
274
275            return CliResult {
276                outcome: Err(e.context(format!("could not load config from {}", &cli.config_file))),
277                project_id: None,
278                telemetry_enabled: false, // Default disabled if we can't load config
279            };
280        }
281    };
282
283    // Extract telemetry info from config
284    let project_id = main_config.project_id.clone();
285    let telemetry_enabled = main_config.telemetry;
286
287    // Run the actual command
288    let outcome = run_command(cli, &mut main_config).await;
289
290    CliResult {
291        outcome,
292        project_id,
293        telemetry_enabled,
294    }
295}
296
297async fn run_command(cli: Cli, config: &mut Config) -> Result<Outcome> {
298    match cli.command {
299        Some(Commands::Init { .. }) => unreachable!(), // Already handled in run_cli
300        Some(Commands::Check) => Check.execute(config).await,
301        Some(Commands::Migration {
302            command,
303            environment,
304        }) => {
305            config.environment = environment;
306            match command {
307                Some(MigrationCommands::New { name }) => {
308                    NewMigration { name }.execute(config).await
309                }
310                Some(MigrationCommands::Pin { migration }) => {
311                    PinMigration { migration }.execute(config).await
312                }
313                Some(MigrationCommands::Build {
314                    migration,
315                    pinned,
316                    variables,
317                }) => {
318                    let vars = match variables {
319                        Some(vars_path) => Some(config.load_variables_from_path(&vars_path).await?),
320                        None => None,
321                    };
322                    BuildMigration {
323                        migration,
324                        pinned,
325                        variables: vars,
326                    }
327                    .execute(config)
328                    .await
329                }
330                Some(MigrationCommands::Apply {
331                    migration,
332                    no_pin,
333                    variables,
334                    yes,
335                    retry,
336                }) => {
337                    let vars = match variables {
338                        Some(vars_path) => Some(config.load_variables_from_path(&vars_path).await?),
339                        None => None,
340                    };
341                    ApplyMigration {
342                        migration,
343                        pinned: !no_pin,
344                        variables: vars,
345                        yes,
346                        retry,
347                    }
348                    .execute(config)
349                    .await
350                }
351                Some(MigrationCommands::Adopt {
352                    migration,
353                    yes,
354                    description,
355                }) => {
356                    AdoptMigration {
357                        migration,
358                        yes,
359                        description,
360                    }
361                    .execute(config)
362                    .await
363                }
364                Some(MigrationCommands::Status) => MigrationStatus.execute(config).await,
365                None => {
366                    eprintln!("No migration subcommand specified");
367                    Ok(Outcome::Unimplemented)
368                }
369            }
370        }
371        Some(Commands::Test { command }) => match command {
372            Some(TestCommands::New { name }) => NewTest { name }.execute(config).await,
373            Some(TestCommands::Build { name }) => BuildTest { name }.execute(config).await,
374            Some(TestCommands::Run { name }) => RunTest { name }.execute(config).await,
375            Some(TestCommands::Compare { name }) => CompareTests { name }.execute(config).await,
376            Some(TestCommands::Expect { name }) => ExpectTest { name }.execute(config).await,
377            None => {
378                eprintln!("No test subcommand specified");
379                Ok(Outcome::Unimplemented)
380            }
381        },
382        None => Ok(Outcome::Unimplemented),
383    }
384}