1use std::error::Error;
2use std::marker::PhantomData;
3use std::path::PathBuf;
4
5use clap::{Args, Parser, Subcommand};
6use vitrail_pg_core::{PostgresMigrator, SchemaAccess};
7
8#[derive(Debug)]
9pub struct VitrailCli<S> {
10 _schema: PhantomData<S>,
11}
12
13impl<S> Default for VitrailCli<S> {
14 fn default() -> Self {
15 Self {
16 _schema: PhantomData,
17 }
18 }
19}
20
21impl<S> VitrailCli<S>
22where
23 S: SchemaAccess,
24{
25 pub async fn run(self) -> Result<(), Box<dyn Error>> {
26 Cli::parse().run::<S>().await
27 }
28}
29
30pub async fn run_cli<S>() -> Result<(), Box<dyn Error>>
31where
32 S: SchemaAccess,
33{
34 VitrailCli::<S>::default().run().await
35}
36
37#[derive(Debug, Parser)]
38#[command(name = "vitrail")]
39#[command(about = "Vitrail CLI for schema-aware PostgreSQL migrations")]
40struct Cli {
41 #[command(subcommand)]
42 command: Command,
43}
44
45impl Cli {
46 async fn run<S>(self) -> Result<(), Box<dyn Error>>
47 where
48 S: SchemaAccess,
49 {
50 self.command.run::<S>().await
51 }
52}
53
54#[derive(Debug, Subcommand)]
55enum Command {
56 #[command(subcommand)]
57 Migrate(MigrateCommand),
58}
59
60impl Command {
61 async fn run<S>(self) -> Result<(), Box<dyn Error>>
62 where
63 S: SchemaAccess,
64 {
65 match self {
66 Self::Migrate(command) => command.run::<S>().await,
67 }
68 }
69}
70
71#[derive(Debug, Subcommand)]
72enum MigrateCommand {
73 #[command(about = "Create a new migration from the current schema")]
74 Dev(MigrateDevArgs),
75 #[command(about = "Apply all migrations from disk to the database")]
76 Deploy(MigrationConnectionArgs),
77 #[command(about = "Show migration status")]
78 Status(MigrationConnectionArgs),
79}
80
81impl MigrateCommand {
82 async fn run<S>(self) -> Result<(), Box<dyn Error>>
83 where
84 S: SchemaAccess,
85 {
86 match self {
87 Self::Dev(args) => args.run::<S>().await,
88 Self::Deploy(args) => args.run_deploy().await,
89 Self::Status(args) => args.run_status().await,
90 }
91 }
92}
93
94#[derive(Debug, Args)]
95struct MigrateDevArgs {
96 #[command(flatten)]
97 connection: MigrationConnectionArgs,
98 #[arg(long, short, help = "Human-readable name for the new migration")]
99 name: String,
100}
101
102impl MigrateDevArgs {
103 async fn run<S>(self) -> Result<(), Box<dyn Error>>
104 where
105 S: SchemaAccess,
106 {
107 let migrator = self.connection.migrator();
108
109 match migrator.generate_migration::<S>(&self.name).await? {
110 Some(generated) => {
111 println!(
112 "Created migration `{}` at `{}`",
113 generated.migration().name(),
114 generated
115 .migration()
116 .sql_path()
117 .expect("generated migration should always have a filesystem path")
118 .display(),
119 );
120 print!("{}", generated.sql());
121 }
122 None => {
123 println!("Schema is already up to date. No migration was created.");
124 }
125 }
126
127 Ok(())
128 }
129}
130
131#[derive(Debug, Args)]
132struct MigrationConnectionArgs {
133 #[arg(long, env = "VITRAIL_DATABASE_URL", help = "PostgreSQL database URL")]
134 database_url: String,
135 #[arg(
136 long,
137 default_value = "migrations",
138 help = "Path to the migrations directory"
139 )]
140 migrations_path: PathBuf,
141}
142
143impl MigrationConnectionArgs {
144 fn migrator(&self) -> PostgresMigrator {
145 PostgresMigrator::new(self.database_url.clone(), self.migrations_path.clone())
146 }
147
148 async fn run_deploy(self) -> Result<(), Box<dyn Error>> {
149 let migrator = self.migrator();
150 let report = migrator.apply_all().await?;
151
152 if report.applied().is_empty() {
153 println!("No pending migrations.");
154 } else {
155 println!("Applied {} migration(s):", report.applied().len());
156 for migration in report.applied() {
157 println!("- {}", migration.name());
158 }
159 }
160
161 if !report.skipped().is_empty() {
162 println!(
163 "Skipped {} already applied migration(s).",
164 report.skipped().len()
165 );
166 }
167
168 Ok(())
169 }
170
171 async fn run_status(self) -> Result<(), Box<dyn Error>> {
172 let migrator = self.migrator();
173 let applied = migrator.applied_migrations().await?;
174 let migration_directory = migrator
175 .migration_directory()
176 .expect("CLI migrator should always use a migration directory");
177 let disk = migration_directory.read_all()?;
178
179 println!(
180 "Migration directory: {}",
181 migration_directory.path().display()
182 );
183 println!("Migrations on disk: {}", disk.len());
184 println!("Migrations applied: {}", applied.len());
185
186 if !disk.is_empty() {
187 println!("On disk:");
188 for migration in &disk {
189 println!("- {}", migration.name());
190 }
191 }
192
193 if !applied.is_empty() {
194 println!("Applied:");
195 for migration in &applied {
196 println!("- {}", migration.name());
197 }
198 }
199
200 Ok(())
201 }
202}