use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Args;
#[derive(Args)]
pub struct MakeMigrationArgs {
description: String,
#[arg(long, default_value = ".")]
path: PathBuf,
}
pub fn run(args: MakeMigrationArgs) -> Result<()> {
let migrations_dir = args.path.join("src").join("migrations");
let manifest = migrations_dir.join("mod.rs");
if !manifest.is_file() {
bail!(
"no migration manifest at {}.\nRun this from a Laterite application or \
plugin crate (one with a src/migrations/mod.rs), or pass --path.",
manifest.display()
);
}
let slug = slug(&args.description);
if slug.is_empty() {
bail!("the description must contain at least one letter or digit");
}
let next = next_sequence(&migrations_dir)?;
let name = format!("{next:04}_{slug}");
let module = format!("m{name}");
let file = migrations_dir.join(format!("{module}.rs"));
if file.exists() {
bail!("{} already exists", file.display());
}
fs::write(&file, blueprint(&name, &args.description))
.with_context(|| format!("writing {}", file.display()))?;
append_to_manifest(&manifest, &module)?;
println!("Created migration {}", file.display());
println!("Listed {module} in {}", manifest.display());
Ok(())
}
fn slug(input: &str) -> String {
let mut out = String::new();
let mut pending = false;
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
if pending && !out.is_empty() {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
pending = false;
} else {
pending = true;
}
}
out
}
fn next_sequence(dir: &Path) -> Result<u32> {
let mut max = 0u32;
for entry in fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
let name = entry?.file_name();
let name = name.to_string_lossy();
if let Some(rest) = name.strip_prefix('m') {
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
if digits.len() == 4 {
if let Ok(n) = digits.parse::<u32>() {
max = max.max(n);
}
}
}
}
Ok(max + 1)
}
fn append_to_manifest(manifest: &Path, module: &str) -> Result<()> {
let src =
fs::read_to_string(manifest).with_context(|| format!("reading {}", manifest.display()))?;
let mut lines: Vec<String> = src.lines().map(str::to_string).collect();
let close = lines
.iter()
.rposition(|l| l.trim() == "}")
.context("could not find the migration_set! block to extend")?;
lines.insert(close, format!(" {module},"));
let mut out = lines.join("\n");
out.push('\n');
fs::write(manifest, out).with_context(|| format!("writing {}", manifest.display()))
}
fn blueprint(name: &str, description: &str) -> String {
let doc = describe(description);
format!(
r#"//! {doc}
use laterite_core::strata::*;
/// The `{name}` migration.
pub struct Migration;
#[async_trait(?Send)]
impl laterite_core::Migration for Migration {{
fn name(&self) -> &str {{
"{name}"
}}
async fn up(&self, s: &mut Schema<'_>) -> CoreResult<()> {{
// Build the schema change here. For example:
//
// s.exec(
// Table::create()
// .table(/* your table */)
// .if_not_exists()
// .col(
// ColumnDef::new(/* id column */)
// .big_integer()
// .not_null()
// .auto_increment()
// .primary_key(),
// )
// .to_owned(),
// )
// .await
let _ = s;
todo!("write the up migration")
}}
// Reversing is opt-in: with no `down`, this migration is irreversible. Add
// one to make `lat migrate rollback` work, for example:
//
// async fn down(&self, s: &mut Schema<'_>) -> CoreResult<()> {{
// s.exec(Table::drop().table(/* your table */).to_owned()).await
// }}
}}
"#
)
}
fn describe(description: &str) -> String {
let spaced = description.replace('_', " ");
let trimmed = spaced.trim();
let mut chars = trimmed.chars();
let mut out = match chars.next() {
Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
};
if !out.ends_with('.') {
out.push('.');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_collapses_separators() {
assert_eq!(slug("create_events"), "create_events");
assert_eq!(slug("add user avatar"), "add_user_avatar");
assert_eq!(slug(" Add User--Avatar!! "), "add_user_avatar");
assert_eq!(slug("!!!"), "");
}
#[test]
fn describe_reads_as_a_sentence() {
assert_eq!(describe("create_events"), "Create events.");
assert_eq!(describe("add user avatar"), "Add user avatar.");
}
#[test]
fn next_sequence_follows_the_highest_file() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
assert_eq!(next_sequence(dir).unwrap(), 1);
fs::write(dir.join("mod.rs"), "").unwrap();
fs::write(dir.join("m0001_create_events.rs"), "").unwrap();
fs::write(dir.join("m0002_add_flags.rs"), "").unwrap();
assert_eq!(next_sequence(dir).unwrap(), 3);
}
#[test]
fn make_scaffolds_and_lists_the_migration() {
let tmp = tempfile::tempdir().unwrap();
let crate_dir = tmp.path();
let migrations = crate_dir.join("src").join("migrations");
fs::create_dir_all(&migrations).unwrap();
fs::write(
migrations.join("mod.rs"),
"//! Manifest.\n\nlaterite_core::migration_set! {\n module_id: \"acme\",\n}\n",
)
.unwrap();
run(MakeMigrationArgs {
description: "create events".to_string(),
path: crate_dir.to_path_buf(),
})
.unwrap();
let file = migrations.join("m0001_create_events.rs");
assert!(file.exists());
let body = fs::read_to_string(&file).unwrap();
assert!(body.contains("\"0001_create_events\""));
assert!(body.contains("pub struct Migration;"));
let manifest = fs::read_to_string(migrations.join("mod.rs")).unwrap();
assert!(manifest.contains(" m0001_create_events,"));
let entry = manifest.find("m0001_create_events").unwrap();
let close = manifest.rfind('}').unwrap();
assert!(entry < close);
run(MakeMigrationArgs {
description: "add flags".to_string(),
path: crate_dir.to_path_buf(),
})
.unwrap();
assert!(migrations.join("m0002_add_flags.rs").exists());
}
#[test]
fn make_without_a_manifest_fails() {
let tmp = tempfile::tempdir().unwrap();
let err = run(MakeMigrationArgs {
description: "create events".to_string(),
path: tmp.path().to_path_buf(),
})
.unwrap_err();
assert!(err.to_string().contains("no migration manifest"));
}
}