Skip to main content

max / quasi

5.7 KB · 158 lines History Blame Raw
1 //! Turning a directory of `.sql` files into a table compiled into the binary.
2 //!
3 //! This is what `sqlx::migrate!` was as a proc macro. A build script instead,
4 //! for one reason worth stating: the checksums are computed at runtime from the
5 //! embedded bytes rather than baked in here, so the runner cannot disagree with
6 //! what it shipped. A macro that recorded both would have two sources for one
7 //! fact.
8 //!
9 //! Nothing in this module links `rusqlite`, and it is behind its own feature so
10 //! that a `build.rs` asking for it does not compile the driver a second time for
11 //! the host.
12 //!
13 //! # Filenames
14 //!
15 //! `<version>_<description>.sql`, the version an integer and the underscores in
16 //! the description becoming spaces. `001_initial_schema.sql` is version 1,
17 //! "initial schema". Anything else in the directory is skipped rather than
18 //! rejected, which is what leaves room for a `README` next to the files.
19 //!
20 //! The convention is `sqlx`'s, deliberately: goingson and Balanced Breakfast
21 //! have sixty-odd files each already named this way, and a runner they could
22 //! not adopt without renaming every one of them is a runner they would not
23 //! adopt.
24
25 use std::fmt::Write as _;
26 use std::path::{Path, PathBuf};
27
28 /// What went wrong reading the directory.
29 ///
30 /// A build script's failure is a panic with a message, so this exists to make
31 /// the message specific rather than to be handled.
32 #[derive(Debug)]
33 pub enum EmbedError {
34 /// The directory could not be read.
35 Unreadable {
36 dir: PathBuf,
37 source: std::io::Error,
38 },
39 /// A file matched the shape but its prefix is not an integer.
40 BadVersion { file: String },
41 /// Two files claim the same version.
42 Duplicate { version: i64 },
43 /// `OUT_DIR` was unset, so this is not running as a build script.
44 NoOutDir,
45 /// The generated file could not be written.
46 Unwritable {
47 dest: PathBuf,
48 source: std::io::Error,
49 },
50 }
51
52 impl std::fmt::Display for EmbedError {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 match self {
55 Self::Unreadable { dir, source } => {
56 write!(
57 f,
58 "migrations directory {} is unreadable: {source}",
59 dir.display()
60 )
61 }
62 Self::BadVersion { file } => {
63 write!(f, "migration {file}: expected an integer version prefix")
64 }
65 Self::Duplicate { version } => write!(f, "two migrations claim version {version}"),
66 Self::NoOutDir => write!(f, "OUT_DIR is unset; this belongs in a build script"),
67 Self::Unwritable { dest, source } => {
68 write!(f, "could not write {}: {source}", dest.display())
69 }
70 }
71 }
72 }
73
74 impl std::error::Error for EmbedError {}
75
76 /// Embed every migration in `dir`, relative to the crate's manifest.
77 ///
78 /// Writes `$OUT_DIR/quasi_migrations.rs`, which
79 /// [`quasi_store::migrations!`](crate::migrations) includes. Emits the
80 /// `rerun-if-changed` lines that make an added or edited migration rebuild the
81 /// crate.
82 ///
83 /// # Errors
84 ///
85 /// If the directory cannot be read, a filename has no integer version, two
86 /// files claim one version, or the generated file cannot be written.
87 pub fn from_dir(dir: impl AsRef<Path>) -> Result<(), EmbedError> {
88 let manifest =
89 std::env::var("CARGO_MANIFEST_DIR").map_or_else(|_| PathBuf::from("."), PathBuf::from);
90 let dir = manifest.join(dir.as_ref());
91 let dir = dir.canonicalize().unwrap_or(dir);
92
93 // The directory itself, so that adding a file is a rebuild and not only
94 // editing one.
95 println!("cargo:rerun-if-changed={}", dir.display());
96
97 let mut entries: Vec<(i64, String, PathBuf)> = Vec::new();
98 let listing = std::fs::read_dir(&dir).map_err(|source| EmbedError::Unreadable {
99 dir: dir.clone(),
100 source,
101 })?;
102
103 for entry in listing {
104 let path = entry
105 .map_err(|source| EmbedError::Unreadable {
106 dir: dir.clone(),
107 source,
108 })?
109 .path();
110 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
111 continue;
112 };
113 let Some((version, rest)) = name.split_once('_') else {
114 continue;
115 };
116 if !Path::new(rest)
117 .extension()
118 .is_some_and(|ext| ext.eq_ignore_ascii_case("sql"))
119 {
120 continue;
121 }
122 let version = version.parse::<i64>().map_err(|_| EmbedError::BadVersion {
123 file: name.to_owned(),
124 })?;
125
126 println!("cargo:rerun-if-changed={}", path.display());
127 entries.push((
128 version,
129 rest.trim_end_matches(".sql").replace('_', " "),
130 path,
131 ));
132 }
133
134 entries.sort_by_key(|(version, _, _)| *version);
135 if let Some(pair) = entries.windows(2).find(|w| w[0].0 == w[1].0) {
136 return Err(EmbedError::Duplicate { version: pair[0].0 });
137 }
138
139 let mut out = String::from(
140 "// @generated by quasi_store::embed::from_dir. Do not edit.\n\
141 static MIGRATIONS: &[::quasi_store::Migration] = &[\n",
142 );
143 for (version, description, path) in &entries {
144 // `include_str!` rather than the bytes inline: the file stays the
145 // source of truth and a diff of this generated file stays readable.
146 let _ = writeln!(
147 out,
148 " ::quasi_store::Migration {{ version: {version}, description: {description:?}, sql: include_str!({:?}) }},",
149 path.display().to_string()
150 );
151 }
152 out.push_str("];\n");
153
154 let dest = Path::new(&std::env::var("OUT_DIR").map_err(|_| EmbedError::NoOutDir)?)
155 .join("quasi_migrations.rs");
156 std::fs::write(&dest, out).map_err(|source| EmbedError::Unwritable { dest, source })
157 }
158