Skip to main content

spawn_db/
cli.rs

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