1use crate::autodetector;
9use crate::error::MigrationError;
10use crate::executor::{Migrator, Report};
11use crate::loader::{self, MigrationGraph};
12use crate::migration::{Migration, next_id, sanitize_slug, slug_from_operations};
13use crate::squash;
14use crate::state::ProjectState;
15use siderite_orm::{Db, ModelMeta};
16use std::path::Path;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct ExitCode(pub u8);
21
22impl ExitCode {
23 pub const SUCCESS: Self = Self(0);
25 pub const FAILURE: Self = Self(1);
27 pub const USAGE: Self = Self(2);
29}
30
31pub async fn run(
40 models: &[&'static ModelMeta],
41 db: &Db,
42 dir: &Path,
43 args: impl IntoIterator<Item = String>,
44) -> Result<ExitCode, MigrationError> {
45 let argv: Vec<String> = args.into_iter().collect();
46 let parsed = parse_args(&argv)?;
47 if parsed.help {
48 print_help();
49 return Ok(ExitCode::SUCCESS);
50 }
51 let command = parsed
52 .command
53 .as_deref()
54 .ok_or_else(|| MigrationError::usage("missing command (try --help)"))?;
55 match command {
56 "makemigrations" => cmd_makemigrations(models, dir, &parsed),
57 "migrate" => cmd_migrate(db, dir, &parsed).await,
58 "rollback" => cmd_rollback(db, dir, &parsed).await,
59 "showmigrations" => cmd_show(db, dir).await,
60 "inspectmigrations" => cmd_inspect(db, dir).await,
61 "squashmigrations" => cmd_squash(dir, &parsed),
62 other => Err(MigrationError::usage(format!(
63 "unknown command `{other}` (try --help)"
64 ))),
65 }
66}
67
68struct Parsed {
69 command: Option<String>,
70 positional: Vec<String>,
71 dry_run: bool,
72 empty: bool,
73 name: Option<String>,
74 steps: Option<u32>,
75 help: bool,
76 lock_timeout_ms: u64,
77}
78
79fn parse_args(argv: &[String]) -> Result<Parsed, MigrationError> {
80 let mut parsed = Parsed {
81 command: None,
82 positional: Vec::new(),
83 dry_run: false,
84 empty: false,
85 name: None,
86 steps: None,
87 help: false,
88 lock_timeout_ms: 30_000,
89 };
90 let mut i = 0;
91 while i < argv.len() {
92 let a = &argv[i];
93 if a == "--help" || a == "-h" {
94 parsed.help = true;
95 i += 1;
96 continue;
97 }
98 if a == "--dry-run" {
99 parsed.dry_run = true;
100 i += 1;
101 continue;
102 }
103 if a == "--empty" {
104 parsed.empty = true;
105 i += 1;
106 continue;
107 }
108 if let Some(v) = strip_flag(a, "--name") {
109 parsed.name = Some(v.to_owned());
110 i += 1;
111 continue;
112 }
113 if a == "--name" {
114 parsed.name = Some(need_value(argv, &mut i, "--name")?);
115 continue;
116 }
117 if let Some(value) = strip_flag(a, "--lock-timeout-ms") {
118 parsed.lock_timeout_ms = parse_lock_timeout(value)?;
119 i += 1;
120 continue;
121 }
122 if a == "--lock-timeout-ms" {
123 let value = need_value(argv, &mut i, "--lock-timeout-ms")?;
124 parsed.lock_timeout_ms = parse_lock_timeout(&value)?;
125 continue;
126 }
127 if let Some(v) = strip_flag(a, "--steps") {
128 parsed.steps = Some(parse_steps(v)?);
129 i += 1;
130 continue;
131 }
132 if a == "--steps" {
133 parsed.steps = Some(parse_steps(&need_value(argv, &mut i, "--steps")?)?);
134 continue;
135 }
136 if a.starts_with('-') {
137 return Err(MigrationError::usage(format!("unknown flag `{a}`")));
138 }
139 if parsed.command.is_none() {
140 parsed.command = Some(a.clone());
141 } else {
142 parsed.positional.push(a.clone());
143 }
144 i += 1;
145 }
146 Ok(parsed)
147}
148
149fn strip_flag<'a>(arg: &'a str, flag: &str) -> Option<&'a str> {
150 let prefix = format!("{flag}=");
151 arg.strip_prefix(&prefix)
152}
153
154fn need_value(argv: &[String], i: &mut usize, flag: &str) -> Result<String, MigrationError> {
155 let value = argv
156 .get(*i + 1)
157 .cloned()
158 .ok_or_else(|| MigrationError::usage(format!("{flag} requires a value")))?;
159 *i += 2;
160 Ok(value)
161}
162
163fn parse_steps(v: &str) -> Result<u32, MigrationError> {
164 v.parse()
165 .map_err(|_| MigrationError::usage(format!("invalid --steps `{v}`")))
166}
167
168fn parse_lock_timeout(value: &str) -> Result<u64, MigrationError> {
169 value
170 .parse::<u64>()
171 .ok()
172 .filter(|timeout| *timeout > 0)
173 .ok_or_else(|| MigrationError::usage("lock timeout must be positive"))
174}
175
176fn print_help() {
177 println!(
178 "\
179siderite migrations
180
181Commands:
182 makemigrations [--name SLUG] [--empty] [--dry-run]
183 migrate [TARGET] [--dry-run]
184 rollback [--steps N | TARGET] [--dry-run]
185 showmigrations
186 inspectmigrations Read-only recovery report (JSON)
187 squashmigrations FROM TO [--name SLUG]
188
189Flags:
190 --lock-timeout-ms N Advisory-lock deadline (default: 30000)
191 --dry-run Print SQL / operations without writing
192 --name SLUG Slug for a new or squashed migration
193 --empty Write an empty migration
194 --steps N Number of migrations to roll back
195 --help Show this help
196"
197 );
198}
199
200fn cmd_makemigrations(
201 models: &[&'static ModelMeta],
202 dir: &Path,
203 parsed: &Parsed,
204) -> Result<ExitCode, MigrationError> {
205 let loaded = loader::load_dir(dir)?;
206 let graph = if loaded.is_empty() {
207 None
208 } else {
209 Some(MigrationGraph::build(loaded.clone())?)
210 };
211 let from = match &graph {
212 Some(g) => g.project_state()?,
213 None => ProjectState::new(),
214 };
215 let operations = if parsed.empty {
216 Vec::new()
217 } else {
218 let to = ProjectState::from_metas(models);
219 autodetector::diff(&from, &to)?
220 };
221 if operations.is_empty() && !parsed.empty {
222 println!("No changes detected.");
223 return Ok(ExitCode::SUCCESS);
224 }
225 let is_first = loaded.is_empty();
226 let slug = match &parsed.name {
227 Some(n) => sanitize_slug(n),
228 None => slug_from_operations(&operations, is_first),
229 };
230 let deps = graph
231 .as_ref()
232 .and_then(|g| g.order.last().cloned())
233 .into_iter()
234 .collect();
235 let id = next_id(&loaded, &slug);
236 let migration = Migration::new(id, deps, operations, true, Vec::new())?;
237 if parsed.dry_run {
238 println!("Would create {}:", migration.id);
239 for op in &migration.operations {
240 println!(" {}", op.summary());
241 }
242 return Ok(ExitCode::SUCCESS);
243 }
244 let path = loader::write_migration(dir, &migration)?;
245 println!("Created {}", path.display());
246 for op in &migration.operations {
247 println!(" {}", op.summary());
248 }
249 Ok(ExitCode::SUCCESS)
250}
251
252async fn cmd_migrate(db: &Db, dir: &Path, parsed: &Parsed) -> Result<ExitCode, MigrationError> {
253 let graph = load_graph(dir)?;
254 let timeout = std::time::Duration::from_millis(parsed.lock_timeout_ms);
255 let migrator = Migrator::new(db, &graph).with_lock_timeout(timeout)?;
256 let target = parsed.positional.first().map(String::as_str);
257 let report = migrator.migrate(target, parsed.dry_run).await;
258 let report = name_lock_holder(&migrator, report).await?;
259 print_report("Applying", &report);
260 Ok(ExitCode::SUCCESS)
261}
262
263async fn cmd_rollback(db: &Db, dir: &Path, parsed: &Parsed) -> Result<ExitCode, MigrationError> {
264 let graph = load_graph(dir)?;
265 let timeout = std::time::Duration::from_millis(parsed.lock_timeout_ms);
266 let migrator = Migrator::new(db, &graph).with_lock_timeout(timeout)?;
267 let target = parsed.positional.first().map(String::as_str);
268 let report = migrator
269 .rollback(target, parsed.steps, parsed.dry_run)
270 .await;
271 let report = name_lock_holder(&migrator, report).await?;
272 print_report("Unapplying", &report);
273 Ok(ExitCode::SUCCESS)
274}
275
276async fn name_lock_holder(
279 migrator: &Migrator<'_>,
280 outcome: Result<Report, MigrationError>,
281) -> Result<Report, MigrationError> {
282 if matches!(outcome, Err(MigrationError::LockTimeout)) {
283 match migrator.lock_holder().await {
284 Ok(Some(holder)) => {
285 eprintln!("the migration lock is held by {holder}");
286 }
287 Ok(None) => eprintln!("the migration lock has since been freed"),
288 Err(err) => eprintln!("cannot identify the lock holder: {err}"),
289 }
290 }
291 outcome
292}
293
294async fn cmd_show(db: &Db, dir: &Path) -> Result<ExitCode, MigrationError> {
295 let graph = load_graph(dir)?;
296 let rows = Migrator::new(db, &graph).show().await?;
297 print!("{}", render_status(&rows));
298 Ok(ExitCode::SUCCESS)
299}
300
301pub fn render_status(rows: &[(String, bool)]) -> String {
310 let mut out = String::new();
311 for (id, applied) in rows {
312 let mark = if *applied { 'X' } else { ' ' };
313 out.push_str(&format!("[{mark}] {id}\n"));
314 }
315 out
316}
317
318async fn cmd_inspect(db: &Db, dir: &Path) -> Result<ExitCode, MigrationError> {
319 let graph = load_graph(dir)?;
320 let report = Migrator::new(db, &graph).inspect_recovery().await?;
321 println!("{}", serde_json::to_string_pretty(&report)?);
322 eprintln!(
323 "Recorded progress is not proof of the live schema. \
324 Reads may race with an active migrator; reconcile catalogs before repair."
325 );
326 Ok(ExitCode::SUCCESS)
327}
328
329fn cmd_squash(dir: &Path, parsed: &Parsed) -> Result<ExitCode, MigrationError> {
330 if parsed.positional.len() < 2 {
331 return Err(MigrationError::usage(
332 "squashmigrations requires FROM and TO ids",
333 ));
334 }
335 let graph = load_graph(dir)?;
336 let migration = squash::squash(
337 &graph,
338 &parsed.positional[0],
339 &parsed.positional[1],
340 parsed.name.as_deref(),
341 )?;
342 if parsed.dry_run {
343 println!(
344 "Would create {} replacing {:?}:",
345 migration.id, migration.replaces
346 );
347 for op in &migration.operations {
348 println!(" {}", op.summary());
349 }
350 return Ok(ExitCode::SUCCESS);
351 }
352 let path = loader::write_migration(dir, &migration)?;
353 println!(
354 "Created {} (replaces {:?})",
355 path.display(),
356 migration.replaces
357 );
358 Ok(ExitCode::SUCCESS)
359}
360
361fn load_graph(dir: &Path) -> Result<MigrationGraph, MigrationError> {
362 MigrationGraph::build(loader::load_dir(dir)?)
363}
364
365fn print_report(verb: &str, report: &Report) {
366 if report.planned.is_empty() {
367 println!("No migrations to apply.");
368 return;
369 }
370 if report.dry_run {
371 println!("Would run:");
372 for id in &report.planned {
373 println!(" {id}");
374 }
375 for sql in &report.sql {
376 println!("{sql};");
377 }
378 return;
379 }
380 for id in &report.applied {
381 println!("{verb} {id}... OK");
382 }
383}
384
385pub fn make_migrations(
390 models: &[&'static ModelMeta],
391 dir: &Path,
392 name: Option<&str>,
393 empty: bool,
394) -> Result<Option<Migration>, MigrationError> {
395 let loaded = loader::load_dir(dir)?;
396 let graph = if loaded.is_empty() {
397 None
398 } else {
399 Some(MigrationGraph::build(loaded.clone())?)
400 };
401 let from = match &graph {
402 Some(g) => g.project_state()?,
403 None => ProjectState::new(),
404 };
405 let operations = if empty {
406 Vec::new()
407 } else {
408 autodetector::diff(&from, &ProjectState::from_metas(models))?
409 };
410 if operations.is_empty() && !empty {
411 return Ok(None);
412 }
413 let slug = match name {
414 Some(n) => sanitize_slug(n),
415 None => slug_from_operations(&operations, loaded.is_empty()),
416 };
417 let deps = graph
418 .as_ref()
419 .and_then(|g| g.order.last().cloned())
420 .into_iter()
421 .collect();
422 let id = next_id(&loaded, &slug);
423 let migration = Migration::new(id, deps, operations, true, Vec::new())?;
424 loader::write_migration(dir, &migration)?;
425 Ok(Some(migration))
426}