Skip to main content

drizzle_migrations/
build.rs

1//! Build-time migration generation helpers.
2//!
3//! This module is intended for `build.rs` flows where users do not want to use
4//! the CLI. It parses Rust schema files, computes diffs against the latest
5//! snapshot in `./drizzle`, and writes a new migration folder when needed.
6//!
7//! # Recommended flow
8//!
9//! ```rust,no_run
10//! use drizzle_migrations::build::{Config, Output, run};
11//! use drizzle_types::Dialect;
12//!
13//! fn main() -> Result<(), Box<dyn std::error::Error>> {
14//!     let cfg = Config::new(Dialect::SQLite)
15//!         .file("src/schema.rs")
16//!         .out("./drizzle");
17//!
18//!     // Tell Cargo to rerun build.rs when schema files change.
19//!     cfg.watch();
20//!
21//!     match run(&cfg)? {
22//!         Output::NoChanges => {}
23//!         Output::Generated { tag, path, .. } => {
24//!             println!("cargo:warning=generated migration {tag} at {}", path.display());
25//!         }
26//!     }
27//!
28//!     Ok(())
29//! }
30//! ```
31
32use crate::config::Tracking;
33use crate::generate::{DiffOptions, diff_with};
34use crate::naming::{PrefixMode, generate_migration_tag_with_mode};
35use crate::parser::SchemaParser;
36use crate::schema::Snapshot;
37pub use drizzle_types::Casing;
38use drizzle_types::{ConfigValue, ConfigValueError, Dialect};
39use serde::Deserialize;
40use std::path::{Path, PathBuf};
41
42/// Build-time migration generation configuration.
43#[derive(Debug, Clone)]
44pub struct Config {
45    files: Vec<PathBuf>,
46    out_dir: PathBuf,
47    dialect: Dialect,
48    casing: Option<Casing>,
49    breakpoints: bool,
50    prefix_mode: PrefixMode,
51    custom_name: Option<String>,
52    url: Option<ConfigValue>,
53    tracking: Tracking,
54    /// Path of the TOML config this was loaded from (if any). Watched by
55    /// [`Config::watch`] alongside the schema files.
56    config_path: Option<PathBuf>,
57    /// Names of env vars referenced by `dbCredentials.url`. Emitted as
58    /// `cargo:rerun-if-env-changed=` by [`Config::watch`].
59    watched_env_vars: Vec<String>,
60    /// Optional last-mile rewrite of the generated statements.
61    transform: Option<StatementTransform>,
62    sqlite_rebuild_data: Option<SqliteRebuildDataSource>,
63}
64
65/// Boxed statement-transform callback.
66///
67/// Wrapped in a newtype so [`Config`] keeps its derived `Debug` and `Clone`
68/// (a bare `Box<dyn Fn>` has neither).
69#[derive(Clone)]
70struct StatementTransform(std::sync::Arc<dyn Fn(Vec<String>) -> Vec<String> + Send + Sync>);
71
72#[derive(Clone, Debug)]
73enum SqliteRebuildDataSource {
74    Inline(crate::sqlite::SqliteRebuildDataPlanRegistry),
75    File(PathBuf),
76}
77
78impl std::fmt::Debug for StatementTransform {
79    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        formatter.write_str("<statement transform>")
81    }
82}
83
84impl Config {
85    /// Create a new configuration.
86    ///
87    /// `out_dir` defaults to `./drizzle`, breakpoints are enabled by default,
88    /// and migration tag prefixes default to timestamp mode. Tracking defaults
89    /// to the dialect-appropriate `Tracking::SQLITE` / `Tracking::POSTGRES`.
90    #[must_use]
91    pub fn new(dialect: Dialect) -> Self {
92        Self {
93            files: Vec::new(),
94            out_dir: PathBuf::from("./drizzle"),
95            dialect,
96            casing: None,
97            breakpoints: true,
98            prefix_mode: PrefixMode::Timestamp,
99            custom_name: None,
100            url: None,
101            tracking: default_tracking(dialect),
102            config_path: None,
103            watched_env_vars: Vec::new(),
104            transform: None,
105            sqlite_rebuild_data: None,
106        }
107    }
108
109    /// Load configuration from a `drizzle.config.toml` file.
110    ///
111    /// Reads `dialect`, `schema` (one path or a list), `out`, `dbCredentials.url`
112    /// (literal string or `{ env = "VAR" }`), and an optional `[migrations]`
113    /// section with tracking overrides and a checked-in
114    /// `sqliteRebuildDataPlan` path.
115    ///
116    /// Anything else in the file is ignored — this loader covers only what
117    /// the build-time generate/migrate flow needs. The CLI's full loader
118    /// handles multi-database configs, filters, and casing-from-TOML.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`BuildError::ConfigNotFound`] if the file is missing,
123    /// [`BuildError::Io`] for other read failures, or [`BuildError::Toml`]
124    /// if it fails to parse.
125    pub fn from_toml(path: impl AsRef<Path>) -> Result<Self, BuildError> {
126        let path = path.as_ref();
127        let content = std::fs::read_to_string(path).map_err(|source| {
128            if source.kind() == std::io::ErrorKind::NotFound {
129                BuildError::ConfigNotFound(path.to_path_buf())
130            } else {
131                BuildError::Io(source)
132            }
133        })?;
134        let raw: RawConfig = toml::from_str(&content).map_err(|source| BuildError::Toml {
135            path: path.to_path_buf(),
136            source,
137        })?;
138
139        let dialect = raw.dialect;
140        let mut cfg = Self::new(dialect);
141        cfg.config_path = Some(path.to_path_buf());
142
143        if let Some(out) = raw.out {
144            cfg.out_dir = out;
145        }
146        cfg.files = match raw.schema {
147            Some(SchemaPaths::One(s)) => vec![PathBuf::from(s)],
148            Some(SchemaPaths::Many(v)) => v.into_iter().map(PathBuf::from).collect(),
149            None => Vec::new(),
150        };
151        if let Some(b) = raw.breakpoints {
152            cfg.breakpoints = b;
153        }
154        if let Some(c) = raw.casing {
155            cfg.casing = Some(c);
156        }
157        if let Some(creds) = raw.db_credentials {
158            if let ConfigValue::Env(ref var) = creds.url {
159                cfg.watched_env_vars.push(var.clone());
160            }
161            cfg.url = Some(creds.url);
162        }
163        if let Some(m) = raw.migrations {
164            if let Some(t) = m.table {
165                cfg.tracking = cfg.tracking.table(t);
166            }
167            if let Some(s) = m.schema {
168                cfg.tracking = cfg.tracking.schema(s);
169            }
170            if let Some(plan) = m.sqlite_rebuild_data_plan {
171                let base = path.parent().unwrap_or_else(|| Path::new("."));
172                cfg.sqlite_rebuild_data = Some(SqliteRebuildDataSource::File(base.join(plan)));
173            }
174        }
175
176        Ok(cfg)
177    }
178
179    /// Add one Rust source file to the build input set.
180    #[must_use]
181    pub fn file(self, path: impl Into<PathBuf>) -> Self {
182        let mut this = self;
183        this.files.push(path.into());
184        this
185    }
186
187    /// Set the output migrations directory.
188    #[must_use]
189    pub fn out(mut self, out_dir: impl Into<PathBuf>) -> Self {
190        self.out_dir = out_dir.into();
191        self
192    }
193
194    /// Set the inferred naming casing strategy.
195    #[must_use]
196    pub const fn casing(mut self, casing: Casing) -> Self {
197        self.casing = Some(casing);
198        self
199    }
200
201    /// Enable or disable statement breakpoints in written SQL.
202    #[must_use]
203    pub const fn breakpoints(mut self, enabled: bool) -> Self {
204        self.breakpoints = enabled;
205        self
206    }
207
208    /// Set migration tag prefix mode.
209    #[must_use]
210    pub const fn prefix_mode(mut self, mode: PrefixMode) -> Self {
211        self.prefix_mode = mode;
212        self
213    }
214
215    /// Set a custom suffix for the generated migration tag.
216    #[must_use]
217    pub fn name(mut self, name: impl Into<String>) -> Self {
218        self.custom_name = Some(name.into());
219        self
220    }
221
222    /// Rewrite the generated statements before they are written to
223    /// `migration.sql`.
224    ///
225    /// This is the supported place for app-level DDL policy — ephemeral
226    /// tables, engine-specific pragmas, `IF NOT EXISTS` conventions, dropping
227    /// statements for objects the app manages itself. Encoding the policy here
228    /// keeps it in version control and re-applies it to every future
229    /// migration; hand-editing generated SQL does neither.
230    ///
231    /// The callback receives the statements in execution order and returns the
232    /// list to write. Returning an empty list makes the run report
233    /// [`Output::NoChanges`] and write nothing.
234    ///
235    /// The snapshot is **not** transformed: it records the schema the diff was
236    /// computed from, and rewriting it would desynchronize the next diff.
237    ///
238    /// # Example
239    ///
240    /// ```rust,no_run
241    /// use drizzle_migrations::build::{Config, run};
242    /// use drizzle_types::Dialect;
243    ///
244    /// let cfg = Config::new(Dialect::SQLite)
245    ///     .file("src/schema.rs")
246    ///     .out("./drizzle")
247    ///     // Session-scoped scratch tables are created by the app at startup,
248    ///     // so migrations must not manage them.
249    ///     .transform_statements(|statements| {
250    ///         statements
251    ///             .into_iter()
252    ///             .filter(|sql| !sql.contains("\"scratch_\""))
253    ///             .collect()
254    ///     });
255    ///
256    /// run(&cfg)?;
257    /// # Ok::<(), drizzle_migrations::BuildError>(())
258    /// ```
259    ///
260    /// Runtime-generation callers do not need this hook: [`crate::Plan`]
261    /// exposes `statements` as a public `Vec<String>`, so they can rewrite the
262    /// plan directly before executing or writing it.
263    #[must_use]
264    pub fn transform_statements(
265        mut self,
266        transform: impl Fn(Vec<String>) -> Vec<String> + Send + Sync + 'static,
267    ) -> Self {
268        self.transform = Some(StatementTransform(std::sync::Arc::new(transform)));
269        self
270    }
271
272    /// Attach typed data movement to SQLite table rebuilds in this generation.
273    ///
274    /// The plan is validated against both schema snapshots and its exact
275    /// predecessor ID. It does not rewrite generated statements after diffing.
276    #[must_use]
277    pub fn sqlite_rebuild_data_plan(mut self, plan: crate::sqlite::SqliteRebuildDataPlan) -> Self {
278        self.sqlite_rebuild_data = Some(SqliteRebuildDataSource::Inline(
279            crate::sqlite::SqliteRebuildDataPlanRegistry::single(plan),
280        ));
281        self
282    }
283
284    /// Attach a versioned registry of snapshot-bound SQLite rebuild plans.
285    #[must_use]
286    pub fn sqlite_rebuild_data_plan_registry(
287        mut self,
288        registry: crate::sqlite::SqliteRebuildDataPlanRegistry,
289    ) -> Self {
290        self.sqlite_rebuild_data = Some(SqliteRebuildDataSource::Inline(registry));
291        self
292    }
293
294    /// Load a checked-in, versioned SQLite rebuild-data plan during normal
295    /// generation.
296    #[must_use]
297    pub fn sqlite_rebuild_data_plan_file(mut self, path: impl Into<PathBuf>) -> Self {
298        self.sqlite_rebuild_data = Some(SqliteRebuildDataSource::File(path.into()));
299        self
300    }
301
302    /// Apply the configured statement transform, if any.
303    fn apply_transform(&self, statements: Vec<String>) -> Vec<String> {
304        match &self.transform {
305            Some(StatementTransform(transform)) => transform(statements),
306            None => statements,
307        }
308    }
309
310    /// Paths cargo must watch: the schema files, the TOML config (if loaded
311    /// via [`Config::from_toml`]), and the migrations output directory.
312    ///
313    /// Split out of [`Config::watch`] so the set is assertable without
314    /// capturing the build script's stdout.
315    fn watch_targets(&self) -> Vec<PathBuf> {
316        let mut targets = self.files.clone();
317        if let Some(cfg_path) = &self.config_path {
318            targets.push(cfg_path.clone());
319        }
320        targets.push(self.out_dir.clone());
321        if let Some(SqliteRebuildDataSource::File(path)) = &self.sqlite_rebuild_data {
322            targets.push(path.clone());
323        }
324        targets
325    }
326
327    /// Emit `cargo:rerun-if-changed=` for schema files, the TOML config (if
328    /// loaded via [`Config::from_toml`]), and the migrations output directory,
329    /// plus `cargo:rerun-if-env-changed=` for any env vars referenced by
330    /// `dbCredentials.url`.
331    ///
332    /// The output directory is watched because the previous-snapshot chain
333    /// under it is a diff input: deleting or reverting a migration folder
334    /// changes what [`run`] generates. Without it, cargo sees no watched path
335    /// change, skips the script, replays the cached "generated migration"
336    /// output, and the migration is silently never regenerated. Cargo scans a
337    /// watched directory recursively, and a not-yet-existing one counts as
338    /// changed — the first run creates it, so this converges.
339    ///
340    /// Call this once after construction so cargo reruns `build.rs` whenever
341    /// any relevant input changes.
342    pub fn watch(&self) {
343        for path in self.watch_targets() {
344            println!("cargo:rerun-if-changed={}", path.display());
345        }
346        for var in &self.watched_env_vars {
347            println!("cargo:rerun-if-env-changed={var}");
348        }
349    }
350
351    /// Dialect this config targets.
352    #[inline]
353    #[must_use]
354    pub const fn dialect(&self) -> Dialect {
355        self.dialect
356    }
357
358    /// Migrations output directory (where generated `migration.sql` /
359    /// `snapshot.json` folders are written).
360    #[inline]
361    #[must_use]
362    pub fn out_dir(&self) -> &Path {
363        &self.out_dir
364    }
365
366    /// Resolved database URL, reading from the environment if configured as
367    /// `{ env = "VAR" }`.
368    ///
369    /// # Errors
370    ///
371    /// Returns [`BuildError::MissingUrl`] if no URL was configured,
372    /// [`BuildError::EnvVarNotSet`] if a referenced env var is unset, or
373    /// [`BuildError::EnvVarNotUnicode`] if it is set but contains invalid UTF-8.
374    pub fn url(&self) -> Result<String, BuildError> {
375        let cred = self.url.as_ref().ok_or(BuildError::MissingUrl)?;
376        cred.resolve().map_err(|e| match e {
377            ConfigValueError::NotPresent(var) => BuildError::EnvVarNotSet(var),
378            ConfigValueError::NotUnicode(var) => BuildError::EnvVarNotUnicode(var),
379        })
380    }
381
382    /// Migration tracking table/schema for this config.
383    ///
384    /// Defaults to the dialect-appropriate `Tracking::SQLITE` /
385    /// `Tracking::POSTGRES`, with overrides applied from
386    /// `[migrations] table = ...` / `schema = ...` in TOML if present.
387    #[inline]
388    #[must_use]
389    pub fn tracking(&self) -> Tracking {
390        self.tracking.clone()
391    }
392}
393
394#[inline]
395fn default_tracking(dialect: Dialect) -> Tracking {
396    match dialect {
397        Dialect::PostgreSQL => Tracking::POSTGRES,
398        _ => Tracking::SQLITE,
399    }
400}
401
402// ============================================================================
403// drizzle.config.toml — minimal shape for build.rs
404// ============================================================================
405
406/// Raw TOML shape — see [`Config::from_toml`] for the user-facing docs.
407///
408/// This deliberately ignores fields the build-time flow doesn't need
409/// (multi-DB, filters, driver, etc.); the CLI's loader covers those.
410#[derive(Debug, Deserialize)]
411#[serde(rename_all = "camelCase")]
412struct RawConfig {
413    dialect: Dialect,
414    #[serde(default)]
415    schema: Option<SchemaPaths>,
416    #[serde(default)]
417    out: Option<PathBuf>,
418    #[serde(default)]
419    breakpoints: Option<bool>,
420    #[serde(default)]
421    casing: Option<Casing>,
422    #[serde(default)]
423    db_credentials: Option<RawCreds>,
424    #[serde(default)]
425    migrations: Option<RawMigrations>,
426}
427
428#[derive(Debug, Deserialize)]
429#[serde(untagged)]
430enum SchemaPaths {
431    One(String),
432    Many(Vec<String>),
433}
434
435#[derive(Debug, Deserialize)]
436struct RawCreds {
437    url: ConfigValue,
438}
439
440#[derive(Debug, Deserialize)]
441#[serde(rename_all = "camelCase")]
442struct RawMigrations {
443    #[serde(default)]
444    table: Option<String>,
445    #[serde(default)]
446    schema: Option<String>,
447    #[serde(default)]
448    sqlite_rebuild_data_plan: Option<PathBuf>,
449}
450
451/// Result of a build-time migration generation run.
452#[derive(Debug, Clone, PartialEq, Eq)]
453pub enum Output {
454    /// No schema changes were detected.
455    NoChanges,
456    /// A new migration folder was written.
457    Generated {
458        /// Generated migration tag (folder name).
459        tag: String,
460        /// Absolute/relative path to the written migration directory.
461        path: PathBuf,
462        /// Number of SQL statements emitted.
463        statement_count: usize,
464    },
465}
466
467impl Output {
468    #[must_use]
469    pub const fn is_generated(&self) -> bool {
470        matches!(self, Self::Generated { .. })
471    }
472}
473
474/// Errors that can occur while generating migrations in `build.rs`.
475#[derive(Debug, thiserror::Error)]
476pub enum BuildError {
477    #[error("unsupported dialect for build generation: {0:?}")]
478    UnsupportedDialect(Dialect),
479
480    #[error("no schema files configured")]
481    MissingSchemaFiles,
482
483    #[error(
484        "SQLite rebuild-data plans cannot be combined with statement transforms; typed plan validation must remain the final migration authority"
485    )]
486    SqliteRebuildDataTransformConflict,
487
488    #[error("failed to read schema file `{path:?}`: {source}")]
489    ReadSchema {
490        path: PathBuf,
491        #[source]
492        source: std::io::Error,
493    },
494
495    #[error("schema source failed to parse:\n{0}")]
496    SchemaParse(String),
497
498    #[error("failed to parse or write migration metadata: {0}")]
499    Io(#[from] std::io::Error),
500
501    #[error("failed to generate migration diff: {0}")]
502    Migration(#[from] crate::writer::MigrationError),
503
504    #[error("config file not found: {}", .0.display())]
505    ConfigNotFound(PathBuf),
506
507    #[error("failed to parse config `{}`: {source}", path.display())]
508    Toml {
509        path: PathBuf,
510        #[source]
511        source: toml::de::Error,
512    },
513
514    #[error("failed to read SQLite rebuild-data plan `{}`: {source}", path.display())]
515    ReadSqliteRebuildDataPlan {
516        path: PathBuf,
517        #[source]
518        source: std::io::Error,
519    },
520
521    #[error("failed to parse SQLite rebuild-data plan `{}`: {source}", path.display())]
522    ParseSqliteRebuildDataPlan {
523        path: PathBuf,
524        #[source]
525        source: serde_json::Error,
526    },
527
528    #[error("no database URL configured (set `dbCredentials.url` in TOML)")]
529    MissingUrl,
530
531    #[error("env var `{0}` not set")]
532    EnvVarNotSet(String),
533
534    #[error("env var `{0}` contains invalid unicode")]
535    EnvVarNotUnicode(String),
536}
537
538fn load_sqlite_rebuild_data_plan(
539    source: &SqliteRebuildDataSource,
540) -> Result<crate::sqlite::SqliteRebuildDataPlanRegistry, BuildError> {
541    match source {
542        SqliteRebuildDataSource::Inline(plan) => Ok(plan.clone()),
543        SqliteRebuildDataSource::File(path) => {
544            let bytes =
545                std::fs::read(path).map_err(|source| BuildError::ReadSqliteRebuildDataPlan {
546                    path: path.clone(),
547                    source,
548                })?;
549            serde_json::from_slice(&bytes).map_err(|source| {
550                BuildError::ParseSqliteRebuildDataPlan {
551                    path: path.clone(),
552                    source,
553                }
554            })
555        }
556    }
557}
558
559/// Generate and write a migration folder when schema changes are detected.
560///
561/// This is the high-level API that handles:
562/// - diffing against the latest local snapshot
563/// - tag generation
564/// - writing `migration.sql` and `snapshot.json` in `./drizzle/<tag>/`
565///
566/// # Example
567///
568/// ```rust,no_run
569/// use drizzle_migrations::build::{Config, Output, run};
570/// use drizzle_types::Dialect;
571///
572/// let cfg = Config::new(Dialect::SQLite)
573///     .file("src/schema.rs")
574///     .out("./drizzle");
575///
576/// let outcome = run(&cfg)?;
577/// if let Output::Generated { tag, .. } = outcome {
578///     println!("generated {tag}");
579/// }
580/// # Ok::<(), drizzle_migrations::BuildError>(())
581/// ```
582///
583/// # Errors
584///
585/// Returns a [`BuildError`] if the config has no schema files, the dialect is
586/// unsupported, schema parsing fails, snapshot/migration generation fails, or
587/// any filesystem operation (read/write) errors while materializing the
588/// migration folder.
589pub fn run(config: &Config) -> Result<Output, BuildError> {
590    if config.files.is_empty() {
591        return Err(BuildError::MissingSchemaFiles);
592    }
593
594    if config.sqlite_rebuild_data.is_some() && config.transform.is_some() {
595        return Err(BuildError::SqliteRebuildDataTransformConflict);
596    }
597
598    if !matches!(config.dialect, Dialect::SQLite | Dialect::PostgreSQL) {
599        return Err(BuildError::UnsupportedDialect(config.dialect));
600    }
601
602    let parse_result = parse_files(&config.files)?;
603    for warning in &parse_result.warnings {
604        println!("cargo:warning=schema parse: {warning}");
605    }
606    // Entities are emitted best-effort even when parsing hit hard errors;
607    // diffing a half-understood schema produces destructive DDL, so fail
608    // loudly instead of quietly reporting "no changes".
609    if !parse_result.errors.is_empty() {
610        return Err(BuildError::SchemaParse(parse_result.errors.join("\n")));
611    }
612    if parse_result.tables.is_empty() && parse_result.indexes.is_empty() {
613        return Ok(Output::NoChanges);
614    }
615
616    let current_snapshot =
617        Snapshot::from_parse_result(&parse_result, config.dialect, config.casing);
618    let previous_snapshot = load_previous_snapshot(&config.out_dir, config.dialect)?;
619    let sqlite_rebuild_data = config
620        .sqlite_rebuild_data
621        .as_ref()
622        .map(load_sqlite_rebuild_data_plan)
623        .transpose()?;
624    let options = match sqlite_rebuild_data {
625        Some(registry) => DiffOptions::new().sqlite_rebuild_data_registry(registry),
626        None => DiffOptions::new(),
627    };
628    let mut generated = diff_with(&previous_snapshot, &current_snapshot, &options)?;
629
630    // App-level statement policy runs before the emptiness check, so a
631    // transform that filters everything out reports NoChanges instead of
632    // writing an empty migration.
633    generated.statements = config.apply_transform(generated.statements);
634
635    if generated.is_empty() {
636        return Ok(Output::NoChanges);
637    }
638
639    for warning in &generated.warnings {
640        println!("cargo:warning={warning}");
641    }
642
643    let next_idx = next_migration_index(&config.out_dir)?;
644    let tag = generate_migration_tag_with_mode(
645        config.prefix_mode,
646        next_idx,
647        config.custom_name.as_deref(),
648    );
649
650    let sql = if config.breakpoints {
651        generated.to_sql()
652    } else {
653        generated.statements.join("\n\n")
654    };
655
656    // Stage-and-rename so a crash never leaves a torn folder, and an existing
657    // tag is refused instead of silently overwritten.
658    let migration_dir =
659        crate::writer::publish_migration_directory(&config.out_dir, &tag, |staging| {
660            std::fs::write(staging.join("migration.sql"), &sql)
661                .map_err(|error| crate::writer::MigrationError::IoError(error.to_string()))?;
662            generated
663                .snapshot
664                .save(&staging.join("snapshot.json"))
665                .map_err(|error| crate::writer::MigrationError::SnapshotError(error.to_string()))?;
666            Ok(())
667        })?;
668
669    Ok(Output::Generated {
670        tag,
671        path: migration_dir,
672        statement_count: generated.statements.len(),
673    })
674}
675
676fn parse_files(files: &[PathBuf]) -> Result<crate::parser::ParseResult, BuildError> {
677    let mut combined = String::new();
678    for path in files {
679        let code = std::fs::read_to_string(path).map_err(|source| BuildError::ReadSchema {
680            path: path.clone(),
681            source,
682        })?;
683        combined.push_str(&code);
684        combined.push('\n');
685    }
686    Ok(SchemaParser::parse(&combined))
687}
688
689fn load_previous_snapshot(out_dir: &Path, dialect: Dialect) -> Result<Snapshot, BuildError> {
690    let v3_entries = collect_v3_migration_dirs(out_dir)?;
691    // Take the newest folder that actually has a snapshot; custom migrations
692    // (`generate --custom`) publish migration.sql without snapshot.json and
693    // must not reset the diff baseline to an empty schema.
694    for (_, migration_dir) in v3_entries.iter().rev() {
695        let snapshot_path = migration_dir.join("snapshot.json");
696        if snapshot_path.exists() {
697            return Snapshot::load(&snapshot_path, dialect).map_err(BuildError::from);
698        }
699    }
700
701    Ok(Snapshot::empty(dialect))
702}
703
704fn next_migration_index(out_dir: &Path) -> Result<u32, BuildError> {
705    let entries = collect_v3_migration_dirs(out_dir)?;
706    let mut max_index: Option<u32> = None;
707
708    for (tag, _) in &entries {
709        let Some(prefix) = tag.split('_').next() else {
710            continue;
711        };
712
713        // Index prefixes are short (`0000`); longer digit runs are timestamp
714        // (14), unix (10), or millisecond (13) prefixes, not indexes.
715        if prefix.len() > 5 || !prefix.chars().all(|c| c.is_ascii_digit()) {
716            continue;
717        }
718
719        if let Ok(idx) = prefix.parse::<u32>() {
720            max_index = Some(max_index.map_or(idx, |curr| curr.max(idx)));
721        }
722    }
723
724    Ok(max_index.map_or_else(
725        || u32::try_from(entries.len()).unwrap_or(u32::MAX),
726        |idx| idx.saturating_add(1),
727    ))
728}
729
730fn collect_v3_migration_dirs(out_dir: &Path) -> Result<Vec<(String, PathBuf)>, BuildError> {
731    if !out_dir.exists() {
732        return Ok(Vec::new());
733    }
734
735    let mut entries = Vec::new();
736    for entry in std::fs::read_dir(out_dir)? {
737        let entry = entry?;
738        if !entry.file_type()?.is_dir() {
739            continue;
740        }
741
742        let tag = entry.file_name().to_string_lossy().to_string();
743        if tag == "meta" {
744            continue;
745        }
746
747        let path = entry.path();
748        if path.join("migration.sql").exists() {
749            entries.push((tag, path));
750        }
751    }
752
753    entries.sort_by(|a, b| a.0.cmp(&b.0));
754    Ok(entries)
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use crate::sqlite::{
761        SQLITE_REBUILD_DATA_PLAN_VERSION, SqliteColumnCopy, SqliteCopyExpression,
762        SqliteDataValidation, SqliteIntegerMapping, SqliteRebuildDataPlan,
763        SqliteRebuildDataPlanRegistry, SqliteTableRebuildPlan,
764    };
765
766    #[test]
767    fn run_creates_then_stabilizes() {
768        let dir = tempfile::tempdir().expect("tempdir");
769        let schema_path = dir.path().join("schema.rs");
770        let out_dir = dir.path().join("drizzle");
771
772        std::fs::write(
773            &schema_path,
774            r#"
775#[SQLiteTable]
776pub struct Users {
777    #[column(primary)]
778    pub id: i64,
779}
780"#,
781        )
782        .expect("write schema");
783
784        let cfg = Config::new(Dialect::SQLite)
785            .file(&schema_path)
786            .out(&out_dir);
787
788        let first = run(&cfg).expect("first generation should succeed");
789        assert!(matches!(first, Output::Generated { .. }));
790        assert!(
791            !out_dir.join("meta").join("_journal.json").exists(),
792            "v3 generation should not create legacy journal metadata"
793        );
794
795        let second = run(&cfg).expect("second generation should succeed");
796        assert_eq!(second, Output::NoChanges);
797    }
798
799    #[test]
800    fn run_applies_snapshot_bound_typed_sqlite_rebuild_data() {
801        let dir = tempfile::tempdir().expect("tempdir");
802        let schema_path = dir.path().join("schema.rs");
803        let out_dir = dir.path().join("drizzle");
804        std::fs::write(
805            &schema_path,
806            r#"
807#[SQLiteTable]
808pub struct Assets {
809    #[column(primary)]
810    pub id: i64,
811    pub digest: Option<String>,
812    pub relation: i64,
813    pub metadata: String,
814}
815"#,
816        )
817        .expect("write predecessor schema");
818        let base = Config::new(Dialect::SQLite)
819            .file(&schema_path)
820            .out(&out_dir)
821            .prefix_mode(PrefixMode::Index);
822        let Output::Generated { path, .. } = run(&base).expect("generate predecessor") else {
823            panic!("expected predecessor migration");
824        };
825        let predecessor = Snapshot::load(&path.join("snapshot.json"), Dialect::SQLite)
826            .expect("load predecessor snapshot");
827
828        std::fs::write(
829            &schema_path,
830            r#"
831#[SQLiteTable(STRICT)]
832pub struct Assets {
833    #[column(primary)]
834    pub id: i64,
835    #[column(blob)]
836    pub digest: Option<Vec<u8>>,
837    pub relation: i64,
838    pub metadata: String,
839}
840"#,
841        )
842        .expect("write current schema");
843        let error = run(&base).expect_err("affinity change without a rebuild plan must fail");
844        let BuildError::Migration(crate::writer::MigrationError::ConfigError(message)) = error
845        else {
846            panic!("unexpected error: {error:?}");
847        };
848        assert!(
849            message.contains("storage affinity without a rebuild-data plan")
850                && message.contains("assets.digest"),
851            "{message}"
852        );
853        let plan = SqliteRebuildDataPlan {
854            predecessor_snapshot_id: uuid::Uuid::parse_str(predecessor.id())
855                .expect("generated predecessor ID is a UUID"),
856            tables: vec![SqliteTableRebuildPlan {
857                table: "assets".to_string(),
858                columns: vec![
859                    SqliteColumnCopy {
860                        target: "digest".to_string(),
861                        expression: SqliteCopyExpression::HexTextToBlob {
862                            source: "digest".to_string(),
863                            bytes: 32,
864                        },
865                    },
866                    SqliteColumnCopy {
867                        target: "relation".to_string(),
868                        expression: SqliteCopyExpression::IntegerMap {
869                            source: "relation".to_string(),
870                            cases: vec![
871                                SqliteIntegerMapping { from: 1, to: 0 },
872                                SqliteIntegerMapping { from: 2, to: 1 },
873                                SqliteIntegerMapping { from: 4, to: 2 },
874                            ],
875                        },
876                    },
877                ],
878                validations: vec![SqliteDataValidation::JsonValid {
879                    column: "metadata".to_string(),
880                }],
881            }],
882        };
883        let registry = SqliteRebuildDataPlanRegistry {
884            version: SQLITE_REBUILD_DATA_PLAN_VERSION,
885            plans: vec![plan],
886        };
887        let plan_path = dir.path().join("rebuild-data.json");
888        std::fs::write(
889            &plan_path,
890            serde_json::to_vec_pretty(&registry).expect("serialize rebuild-data registry"),
891        )
892        .expect("write rebuild-data plan");
893        let configured = Config::new(Dialect::SQLite)
894            .file(&schema_path)
895            .out(&out_dir)
896            .prefix_mode(PrefixMode::Index)
897            .sqlite_rebuild_data_plan_file(&plan_path);
898        let Output::Generated { tag, path, .. } = run(&configured).expect("generate typed rebuild")
899        else {
900            panic!("expected typed rebuild migration");
901        };
902        let sql = std::fs::read_to_string(path.join("migration.sql"))
903            .expect("read generated rebuild migration");
904        assert!(
905            sql.contains("coalesce(length(unhex(`digest`)), -1) <> 32"),
906            "{sql}"
907        );
908        assert!(sql.contains("unhex(`digest`)"), "{sql}");
909        assert!(
910            sql.contains("CASE `relation` WHEN 1 THEN 0 WHEN 2 THEN 1 WHEN 4 THEN 2 ELSE NULL END"),
911            "{sql}"
912        );
913        assert!(!sql.contains("IF EXISTS"), "{sql}");
914        assert!(!sql.contains("IF NOT EXISTS"), "{sql}");
915
916        let connection = rusqlite::Connection::open_in_memory().expect("open SQLite");
917        connection
918            .execute_batch(
919                "CREATE TABLE assets (id INTEGER PRIMARY KEY, digest TEXT, relation INTEGER NOT NULL, metadata TEXT NOT NULL);\
920                 INSERT INTO assets VALUES (1, '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f', 1, '[]');\
921                 INSERT INTO assets VALUES (2, NULL, 4, '{}');",
922            )
923            .expect("seed valid predecessor rows");
924        apply_generated_sql(&connection, &sql).expect("apply generated rebuild");
925        let rows = connection
926            .prepare("SELECT id, typeof(digest), length(digest), relation, metadata FROM assets ORDER BY id")
927            .expect("prepare migrated read")
928            .query_map([], |row| {
929                Ok((
930                    row.get::<_, i64>(0)?,
931                    row.get::<_, String>(1)?,
932                    row.get::<_, Option<i64>>(2)?,
933                    row.get::<_, i64>(3)?,
934                    row.get::<_, String>(4)?,
935                ))
936            })
937            .expect("query migrated rows")
938            .collect::<Result<Vec<_>, _>>()
939            .expect("decode migrated rows");
940        assert_eq!(
941            rows,
942            vec![
943                (1, "blob".to_string(), Some(32), 0, "[]".to_string()),
944                (2, "null".to_string(), None, 2, "{}".to_string()),
945            ]
946        );
947        let guard_count: i64 = connection
948            .query_row(
949                "SELECT count(*) FROM sqlite_temp_master WHERE name LIKE '__drizzle_rebuild_guard_%'",
950                [],
951                |row| row.get(0),
952            )
953            .expect("query temp guards");
954        assert_eq!(guard_count, 0, "successful migration must drop its guard");
955
956        let migration = crate::Migration::new(&tag, &sql);
957        let migrations = crate::Migrations::new(vec![migration], Dialect::SQLite);
958        assert_eq!(migrations.pending::<String>(&[]).count(), 1);
959        assert_eq!(migrations.pending(&[tag]).count(), 0);
960        assert!(
961            apply_generated_sql(&connection, &sql).is_err(),
962            "forced replay must fail loudly"
963        );
964
965        assert_invalid_rebuild_row(
966            &sql,
967            "'zz0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'",
968            "1",
969            "'[]'",
970        );
971        assert_invalid_rebuild_row(&sql, "NULL", "3", "'[]'");
972        assert_invalid_rebuild_row(&sql, "NULL", "1", "'{' ");
973
974        assert_eq!(
975            run(&configured).expect("reopen generation with historical registry"),
976            Output::NoChanges
977        );
978    }
979
980    fn apply_generated_sql(connection: &rusqlite::Connection, sql: &str) -> rusqlite::Result<()> {
981        for statement in sql.split("\n--> statement-breakpoint\n") {
982            connection.execute_batch(statement)?;
983        }
984        Ok(())
985    }
986
987    fn assert_invalid_rebuild_row(sql: &str, digest: &str, relation: &str, metadata: &str) {
988        let connection = rusqlite::Connection::open_in_memory().expect("open SQLite");
989        connection
990            .execute_batch(&format!(
991                "CREATE TABLE assets (id INTEGER PRIMARY KEY, digest TEXT, relation INTEGER NOT NULL, metadata TEXT NOT NULL);\
992                 INSERT INTO assets VALUES (1, {digest}, {relation}, {metadata});"
993            ))
994            .expect("seed invalid predecessor row");
995        assert!(
996            apply_generated_sql(&connection, sql).is_err(),
997            "invalid predecessor row unexpectedly migrated: digest={digest}, relation={relation}, metadata={metadata}"
998        );
999        let table_sql: String = connection
1000            .query_row(
1001                "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'assets'",
1002                [],
1003                |row| row.get(0),
1004            )
1005            .expect("predecessor table remains");
1006        assert!(!table_sql.contains("STRICT"), "copy began before preflight");
1007    }
1008
1009    #[test]
1010    fn run_fails_loudly_on_parse_errors() {
1011        let dir = tempfile::tempdir().expect("tempdir");
1012        let schema_path = dir.path().join("schema.rs");
1013        let out_dir = dir.path().join("drizzle");
1014
1015        // Unbalanced brace: syn cannot parse the file. Before the errors
1016        // channel was wired up this quietly produced `NoChanges`.
1017        std::fs::write(
1018            &schema_path,
1019            r#"
1020#[SQLiteTable]
1021pub struct Users {
1022    #[column(primary)]
1023    pub id: i64,
1024"#,
1025        )
1026        .expect("write schema");
1027
1028        let cfg = Config::new(Dialect::SQLite)
1029            .file(&schema_path)
1030            .out(&out_dir);
1031
1032        let error = run(&cfg).expect_err("parse failure must not be silent");
1033        assert!(
1034            matches!(error, BuildError::SchemaParse(_)),
1035            "expected SchemaParse, got {error:?}"
1036        );
1037        assert!(
1038            !out_dir.exists(),
1039            "no migration output should be written on parse failure"
1040        );
1041    }
1042
1043    #[test]
1044    fn run_accepts_multiple_files() {
1045        let dir = tempfile::tempdir().expect("tempdir");
1046        let users_path = dir.path().join("users.rs");
1047        let posts_path = dir.path().join("posts.rs");
1048        let schema_path = dir.path().join("schema.rs");
1049        let out_dir = dir.path().join("drizzle");
1050
1051        std::fs::write(
1052            &users_path,
1053            r#"
1054#[SQLiteTable]
1055pub struct Users {
1056    #[column(primary)]
1057    pub id: i64,
1058    pub name: String,
1059}
1060"#,
1061        )
1062        .expect("write users schema");
1063
1064        std::fs::write(
1065            &posts_path,
1066            r#"
1067#[SQLiteTable]
1068pub struct Posts {
1069    #[column(primary)]
1070    pub id: i64,
1071    #[column(references = Users::id)]
1072    pub author_id: i64,
1073}
1074"#,
1075        )
1076        .expect("write posts schema");
1077
1078        std::fs::write(
1079            &schema_path,
1080            r#"
1081#[derive(SQLiteSchema)]
1082pub struct Schema {
1083    pub users: Users,
1084    pub posts: Posts,
1085}
1086"#,
1087        )
1088        .expect("write root schema");
1089
1090        let cfg = Config::new(Dialect::SQLite)
1091            .file(&users_path)
1092            .file(&posts_path)
1093            .file(&schema_path)
1094            .out(&out_dir);
1095
1096        let outcome = run(&cfg).expect("generation should succeed");
1097        let Output::Generated { path, .. } = outcome else {
1098            panic!("expected a migration to be generated");
1099        };
1100
1101        let migration_sql_path = path.join("migration.sql");
1102        assert!(migration_sql_path.exists(), "migration.sql should exist");
1103        assert!(
1104            path.join("snapshot.json").exists(),
1105            "snapshot.json should exist"
1106        );
1107
1108        let migration_sql =
1109            std::fs::read_to_string(&migration_sql_path).expect("read generated migration.sql");
1110        let mut statements: Vec<_> = migration_sql
1111            .split("\n--> statement-breakpoint\n")
1112            .map(str::to_string)
1113            .collect();
1114        statements.sort();
1115
1116        let mut expected = vec![
1117            "CREATE TABLE `posts` (\n\t`id` INTEGER PRIMARY KEY,\n\t`author_id` INTEGER NOT NULL,\n\tCONSTRAINT `fk_posts_author_id_users_id_fk` FOREIGN KEY (`author_id`) REFERENCES `users`(`id`)\n);".to_string(),
1118            "CREATE TABLE `users` (\n\t`id` INTEGER PRIMARY KEY,\n\t`name` TEXT NOT NULL\n);".to_string(),
1119        ];
1120        expected.sort();
1121
1122        assert_eq!(statements, expected, "unexpected generated migration SQL");
1123    }
1124
1125    /// Write a one-table schema plus its root schema struct, returning
1126    /// `(schema_file, out_dir)`.
1127    fn two_table_schema(dir: &Path) -> (PathBuf, PathBuf) {
1128        let schema_path = dir.join("schema.rs");
1129        std::fs::write(
1130            &schema_path,
1131            r#"
1132#[SQLiteTable]
1133pub struct Users {
1134    #[column(primary)]
1135    pub id: i64,
1136    pub name: String,
1137}
1138
1139#[SQLiteTable]
1140pub struct ScratchCache {
1141    #[column(primary)]
1142    pub id: i64,
1143}
1144
1145#[derive(SQLiteSchema)]
1146pub struct Schema {
1147    pub users: Users,
1148    pub scratch_cache: ScratchCache,
1149}
1150"#,
1151        )
1152        .expect("write schema");
1153        (schema_path, dir.join("drizzle"))
1154    }
1155
1156    #[test]
1157    fn transform_statements_rewrites_generated_sql() {
1158        let dir = tempfile::tempdir().expect("tempdir");
1159        let (schema_path, out_dir) = two_table_schema(dir.path());
1160
1161        let cfg = Config::new(Dialect::SQLite)
1162            .file(&schema_path)
1163            .out(&out_dir)
1164            // App-level policy: scratch tables are provisioned at runtime, so
1165            // migrations must not own them.
1166            .transform_statements(|statements| {
1167                statements
1168                    .into_iter()
1169                    .filter(|sql| !sql.contains("scratch_cache"))
1170                    .collect()
1171            });
1172
1173        let Output::Generated {
1174            path,
1175            statement_count,
1176            ..
1177        } = run(&cfg).expect("generation should succeed")
1178        else {
1179            panic!("expected a migration to be generated");
1180        };
1181
1182        assert_eq!(statement_count, 1, "transform dropped one statement");
1183        let sql = std::fs::read_to_string(path.join("migration.sql")).expect("read migration.sql");
1184        assert!(sql.contains("`users`"), "{sql}");
1185        assert!(
1186            !sql.contains("scratch_cache"),
1187            "filtered statement leaked into migration.sql: {sql}"
1188        );
1189        assert!(
1190            path.join("snapshot.json").exists(),
1191            "the snapshot still records the untransformed schema"
1192        );
1193    }
1194
1195    #[test]
1196    fn transform_statements_can_append_statements() {
1197        let dir = tempfile::tempdir().expect("tempdir");
1198        let (schema_path, out_dir) = two_table_schema(dir.path());
1199
1200        let cfg = Config::new(Dialect::SQLite)
1201            .file(&schema_path)
1202            .out(&out_dir)
1203            .transform_statements(|mut statements| {
1204                statements.push("CREATE INDEX `users_name_idx` ON `users` (`name`);".to_string());
1205                statements
1206            });
1207
1208        let Output::Generated {
1209            path,
1210            statement_count,
1211            ..
1212        } = run(&cfg).expect("generation should succeed")
1213        else {
1214            panic!("expected a migration to be generated");
1215        };
1216
1217        assert_eq!(statement_count, 3);
1218        let sql = std::fs::read_to_string(path.join("migration.sql")).expect("read migration.sql");
1219        assert!(sql.contains("users_name_idx"), "{sql}");
1220    }
1221
1222    #[test]
1223    fn transform_statements_emptying_the_plan_reports_no_changes() {
1224        let dir = tempfile::tempdir().expect("tempdir");
1225        let (schema_path, out_dir) = two_table_schema(dir.path());
1226
1227        let cfg = Config::new(Dialect::SQLite)
1228            .file(&schema_path)
1229            .out(&out_dir)
1230            .transform_statements(|_| Vec::new());
1231
1232        assert_eq!(run(&cfg).expect("run"), Output::NoChanges);
1233        assert!(
1234            !out_dir.exists(),
1235            "an emptied plan must not write a migration folder"
1236        );
1237    }
1238
1239    #[test]
1240    fn config_without_transform_is_unchanged() {
1241        let cfg = Config::new(Dialect::SQLite);
1242        assert_eq!(
1243            cfg.apply_transform(vec!["SELECT 1".to_string()]),
1244            vec!["SELECT 1".to_string()]
1245        );
1246        // The boxed callback must not break Config's derived Debug/Clone.
1247        let cloned = cfg.clone().transform_statements(|s| s);
1248        assert!(format!("{cloned:?}").contains("statement transform"));
1249    }
1250
1251    #[test]
1252    fn typed_rebuild_plan_rejects_statement_transform() {
1253        let plan = SqliteRebuildDataPlan {
1254            predecessor_snapshot_id: uuid::Uuid::new_v4(),
1255            tables: Vec::new(),
1256        };
1257        let cfg = Config::new(Dialect::SQLite)
1258            .file("schema.rs")
1259            .sqlite_rebuild_data_plan(plan)
1260            .transform_statements(|statements| statements);
1261
1262        assert!(matches!(
1263            run(&cfg),
1264            Err(BuildError::SqliteRebuildDataTransformConflict)
1265        ));
1266    }
1267
1268    #[test]
1269    fn from_toml_loads_minimal_sqlite() {
1270        let dir = tempfile::tempdir().expect("tempdir");
1271        let cfg_path = dir.path().join("drizzle.config.toml");
1272        std::fs::write(
1273            &cfg_path,
1274            r#"
1275dialect = "sqlite"
1276schema = "src/schema.rs"
1277out = "./drizzle"
1278
1279[migrations]
1280sqliteRebuildDataPlan = "plans/rebuild-data.json"
1281
1282[dbCredentials]
1283url = "./dev.db"
1284"#,
1285        )
1286        .expect("write config");
1287
1288        let cfg = Config::from_toml(&cfg_path).expect("load toml");
1289
1290        assert_eq!(cfg.dialect(), Dialect::SQLite);
1291        assert_eq!(cfg.out_dir(), Path::new("./drizzle"));
1292        assert_eq!(cfg.url().expect("resolve url"), "./dev.db");
1293        assert_eq!(cfg.tracking(), Tracking::SQLITE);
1294        assert!(
1295            cfg.watch_targets()
1296                .contains(&dir.path().join("plans/rebuild-data.json")),
1297            "checked-in rebuild plan must be resolved relative to and watched with its config"
1298        );
1299    }
1300
1301    #[test]
1302    fn from_toml_handles_env_url_and_multiple_schemas() {
1303        let dir = tempfile::tempdir().expect("tempdir");
1304        let cfg_path = dir.path().join("drizzle.config.toml");
1305        std::fs::write(
1306            &cfg_path,
1307            r#"
1308dialect = "postgresql"
1309schema = ["src/users.rs", "src/posts.rs"]
1310
1311[dbCredentials]
1312url = { env = "DRIZZLE_BUILD_TEST_URL" }
1313
1314[migrations]
1315table = "my_migrations"
1316schema = "drizzle_meta"
1317"#,
1318        )
1319        .expect("write config");
1320
1321        let cfg = Config::from_toml(&cfg_path).expect("load toml");
1322        assert_eq!(cfg.dialect(), Dialect::PostgreSQL);
1323
1324        let tracking = cfg.tracking();
1325        assert_eq!(tracking.table, "my_migrations");
1326        assert_eq!(tracking.schema.as_deref(), Some("drizzle_meta"));
1327
1328        // SAFETY: single-test scope, no other env consumers race here.
1329        unsafe { std::env::set_var("DRIZZLE_BUILD_TEST_URL", "postgres://x") };
1330        assert_eq!(cfg.url().expect("resolve env"), "postgres://x");
1331        unsafe { std::env::remove_var("DRIZZLE_BUILD_TEST_URL") };
1332
1333        let err = cfg.url().expect_err("missing env var should error");
1334        assert!(
1335            matches!(err, BuildError::EnvVarNotSet(ref v) if v == "DRIZZLE_BUILD_TEST_URL"),
1336            "unexpected error: {err:?}"
1337        );
1338    }
1339
1340    #[test]
1341    fn from_toml_missing_url_errors_lazily() {
1342        let dir = tempfile::tempdir().expect("tempdir");
1343        let cfg_path = dir.path().join("drizzle.config.toml");
1344        std::fs::write(
1345            &cfg_path,
1346            r#"
1347dialect = "sqlite"
1348schema = "src/schema.rs"
1349"#,
1350        )
1351        .expect("write config");
1352
1353        // Missing dbCredentials is fine — only fails when url() is called.
1354        let cfg = Config::from_toml(&cfg_path).expect("load toml");
1355        assert!(matches!(cfg.url(), Err(BuildError::MissingUrl)));
1356    }
1357
1358    #[test]
1359    fn watch_targets_include_out_dir_and_schema_files() {
1360        let cfg = Config::new(Dialect::SQLite)
1361            .file("src/schema.rs")
1362            .file("src/posts.rs")
1363            .out("./drizzle");
1364
1365        let targets = cfg.watch_targets();
1366
1367        assert!(
1368            targets.contains(&PathBuf::from("src/schema.rs"))
1369                && targets.contains(&PathBuf::from("src/posts.rs")),
1370            "schema files must be watched: {targets:?}"
1371        );
1372        // run() diffs against the snapshot chain under out_dir, so deleting a
1373        // migration folder has to retrigger build.rs; without this watch cargo
1374        // replays the cached script output and never regenerates it.
1375        assert!(
1376            targets.contains(&PathBuf::from("./drizzle")),
1377            "out_dir must be watched: {targets:?}"
1378        );
1379    }
1380
1381    #[test]
1382    fn watch_targets_include_the_toml_config_path() {
1383        let dir = tempfile::tempdir().expect("tempdir");
1384        let cfg_path = dir.path().join("drizzle.config.toml");
1385        std::fs::write(
1386            &cfg_path,
1387            r#"
1388dialect = "sqlite"
1389schema = "src/schema.rs"
1390out = "./migrations-out"
1391"#,
1392        )
1393        .expect("write config");
1394
1395        let cfg = Config::from_toml(&cfg_path).expect("load toml");
1396        let targets = cfg.watch_targets();
1397
1398        assert!(
1399            targets.contains(&cfg_path),
1400            "config path must be watched: {targets:?}"
1401        );
1402        assert!(
1403            targets.contains(&PathBuf::from("src/schema.rs")),
1404            "schema files must be watched: {targets:?}"
1405        );
1406        assert!(
1407            targets.contains(&PathBuf::from("./migrations-out")),
1408            "out_dir must be watched: {targets:?}"
1409        );
1410    }
1411}