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 target: 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.target.clone()).await {
260        Ok(cfg) => cfg,
261        Err(e) => {
262            // If config doesn't exist, show helpful message
263            if !config_exists {
264                crate::show_telemetry_notice();
265                eprintln!("No spawn.toml configuration file found.");
266                eprintln!("Run `spawn init` to create a new spawn project.");
267                return CliResult {
268                    outcome: Err(anyhow!("Configuration file not found")),
269                    project_id: None,
270                    telemetry_enabled: false,
271                };
272            }
273
274            return CliResult {
275                outcome: Err(e.context(format!("could not load config from {}", &cli.config_file))),
276                project_id: None,
277                telemetry_enabled: false, // Default disabled if we can't load config
278            };
279        }
280    };
281
282    // Extract telemetry info from config
283    let project_id = main_config.project_id.clone();
284    let telemetry_enabled = main_config.telemetry;
285
286    // Run the actual command
287    let outcome = run_command(cli, &mut main_config).await;
288
289    CliResult {
290        outcome,
291        project_id,
292        telemetry_enabled,
293    }
294}
295
296async fn run_command(cli: Cli, config: &mut Config) -> Result<Outcome> {
297    match cli.command {
298        Some(Commands::Init { .. }) => unreachable!(), // Already handled in run_cli
299        Some(Commands::Check) => Check.execute(config).await,
300        Some(Commands::Migration {
301            command,
302            environment,
303        }) => {
304            config.environment = environment;
305            match command {
306                Some(MigrationCommands::New { name }) => {
307                    NewMigration { name }.execute(config).await
308                }
309                Some(MigrationCommands::Pin { migration }) => {
310                    PinMigration { migration }.execute(config).await
311                }
312                Some(MigrationCommands::Build {
313                    migration,
314                    pinned,
315                    variables,
316                }) => {
317                    let vars = match variables {
318                        Some(vars_path) => Some(config.load_variables_from_path(&vars_path).await?),
319                        None => None,
320                    };
321                    BuildMigration {
322                        migration,
323                        pinned,
324                        variables: vars,
325                    }
326                    .execute(config)
327                    .await
328                }
329                Some(MigrationCommands::Apply {
330                    migration,
331                    no_pin,
332                    variables,
333                    yes,
334                    retry,
335                }) => {
336                    let vars = match variables {
337                        Some(vars_path) => Some(config.load_variables_from_path(&vars_path).await?),
338                        None => None,
339                    };
340                    ApplyMigration {
341                        migration,
342                        pinned: !no_pin,
343                        variables: vars,
344                        yes,
345                        retry,
346                    }
347                    .execute(config)
348                    .await
349                }
350                Some(MigrationCommands::Adopt {
351                    migration,
352                    yes,
353                    description,
354                }) => {
355                    AdoptMigration {
356                        migration,
357                        yes,
358                        description,
359                    }
360                    .execute(config)
361                    .await
362                }
363                Some(MigrationCommands::Status) => MigrationStatus.execute(config).await,
364                None => {
365                    eprintln!("No migration subcommand specified");
366                    Ok(Outcome::Unimplemented)
367                }
368            }
369        }
370        Some(Commands::Test { command }) => match command {
371            Some(TestCommands::New { name }) => NewTest { name }.execute(config).await,
372            Some(TestCommands::Build { name }) => BuildTest { name }.execute(config).await,
373            Some(TestCommands::Run { name }) => RunTest { name }.execute(config).await,
374            Some(TestCommands::Compare { name }) => CompareTests { name }.execute(config).await,
375            Some(TestCommands::Expect { name }) => ExpectTest { name }.execute(config).await,
376            None => {
377                eprintln!("No test subcommand specified");
378                Ok(Outcome::Unimplemented)
379            }
380        },
381        None => Ok(Outcome::Unimplemented),
382    }
383}