//! Turning a directory of `.sql` files into a table compiled into the binary. //! //! This is what `sqlx::migrate!` was as a proc macro. A build script instead, //! for one reason worth stating: the checksums are computed at runtime from the //! embedded bytes rather than baked in here, so the runner cannot disagree with //! what it shipped. A macro that recorded both would have two sources for one //! fact. //! //! Nothing in this module links `rusqlite`, and it is behind its own feature so //! that a `build.rs` asking for it does not compile the driver a second time for //! the host. //! //! # Filenames //! //! `_.sql`, the version an integer and the underscores in //! the description becoming spaces. `001_initial_schema.sql` is version 1, //! "initial schema". Anything else in the directory is skipped rather than //! rejected, which is what leaves room for a `README` next to the files. //! //! The convention is `sqlx`'s, deliberately: goingson and Balanced Breakfast //! have sixty-odd files each already named this way, and a runner they could //! not adopt without renaming every one of them is a runner they would not //! adopt. use std::fmt::Write as _; use std::path::{Path, PathBuf}; /// What went wrong reading the directory. /// /// A build script's failure is a panic with a message, so this exists to make /// the message specific rather than to be handled. #[derive(Debug)] pub enum EmbedError { /// The directory could not be read. Unreadable { dir: PathBuf, source: std::io::Error, }, /// A file matched the shape but its prefix is not an integer. BadVersion { file: String }, /// Two files claim the same version. Duplicate { version: i64 }, /// `OUT_DIR` was unset, so this is not running as a build script. NoOutDir, /// The generated file could not be written. Unwritable { dest: PathBuf, source: std::io::Error, }, } impl std::fmt::Display for EmbedError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Unreadable { dir, source } => { write!( f, "migrations directory {} is unreadable: {source}", dir.display() ) } Self::BadVersion { file } => { write!(f, "migration {file}: expected an integer version prefix") } Self::Duplicate { version } => write!(f, "two migrations claim version {version}"), Self::NoOutDir => write!(f, "OUT_DIR is unset; this belongs in a build script"), Self::Unwritable { dest, source } => { write!(f, "could not write {}: {source}", dest.display()) } } } } impl std::error::Error for EmbedError {} /// Embed every migration in `dir`, relative to the crate's manifest. /// /// Writes `$OUT_DIR/quasi_migrations.rs`, which /// [`quasi_store::migrations!`](crate::migrations) includes. Emits the /// `rerun-if-changed` lines that make an added or edited migration rebuild the /// crate. /// /// # Errors /// /// If the directory cannot be read, a filename has no integer version, two /// files claim one version, or the generated file cannot be written. pub fn from_dir(dir: impl AsRef) -> Result<(), EmbedError> { let manifest = std::env::var("CARGO_MANIFEST_DIR").map_or_else(|_| PathBuf::from("."), PathBuf::from); let dir = manifest.join(dir.as_ref()); let dir = dir.canonicalize().unwrap_or(dir); // The directory itself, so that adding a file is a rebuild and not only // editing one. println!("cargo:rerun-if-changed={}", dir.display()); let mut entries: Vec<(i64, String, PathBuf)> = Vec::new(); let listing = std::fs::read_dir(&dir).map_err(|source| EmbedError::Unreadable { dir: dir.clone(), source, })?; for entry in listing { let path = entry .map_err(|source| EmbedError::Unreadable { dir: dir.clone(), source, })? .path(); let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; let Some((version, rest)) = name.split_once('_') else { continue; }; if !Path::new(rest) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("sql")) { continue; } let version = version.parse::().map_err(|_| EmbedError::BadVersion { file: name.to_owned(), })?; println!("cargo:rerun-if-changed={}", path.display()); entries.push(( version, rest.trim_end_matches(".sql").replace('_', " "), path, )); } entries.sort_by_key(|(version, _, _)| *version); if let Some(pair) = entries.windows(2).find(|w| w[0].0 == w[1].0) { return Err(EmbedError::Duplicate { version: pair[0].0 }); } let mut out = String::from( "// @generated by quasi_store::embed::from_dir. Do not edit.\n\ static MIGRATIONS: &[::quasi_store::Migration] = &[\n", ); for (version, description, path) in &entries { // `include_str!` rather than the bytes inline: the file stays the // source of truth and a diff of this generated file stays readable. let _ = writeln!( out, " ::quasi_store::Migration {{ version: {version}, description: {description:?}, sql: include_str!({:?}) }},", path.display().to_string() ); } out.push_str("];\n"); let dest = Path::new(&std::env::var("OUT_DIR").map_err(|_| EmbedError::NoOutDir)?) .join("quasi_migrations.rs"); std::fs::write(&dest, out).map_err(|source| EmbedError::Unwritable { dest, source }) }