Skip to main content

minco_db/
lib.rs

1//! Provider-neutral database migration and seed lifecycle models.
2#![forbid(unsafe_code)]
3
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256, Sha384};
6use std::{
7    collections::{BTreeMap, BTreeSet},
8    fs,
9    path::{Path, PathBuf},
10};
11use thiserror::Error;
12
13mod seed;
14
15pub use seed::*;
16
17pub const MIGRATION_SET_MANIFEST: &str = ".minco-migrations.toml";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum DatabaseBackend {
22    Postgres,
23    Sqlite,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum MigrationRisk {
29    Additive,
30    DataRewrite,
31    Destructive,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct MigrationCatalog {
36    pub schema_version: u32,
37    pub digest: String,
38    pub sets: Vec<MigrationSet>,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct MigrationPlan {
43    pub schema_version: u32,
44    pub catalog_digest: String,
45    pub selected_set: Option<String>,
46    pub digest: String,
47    pub sets: Vec<MigrationSet>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct MigrationSet {
52    pub id: String,
53    pub owner: String,
54    pub backend: DatabaseBackend,
55    pub root: PathBuf,
56    pub history_table: String,
57    pub depends_on: Vec<String>,
58    pub verify_tables: Vec<String>,
59    pub digest: String,
60    pub migrations: Vec<Migration>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct Migration {
65    pub id: String,
66    pub version: i64,
67    pub description: String,
68    pub path: PathBuf,
69    pub sha256: String,
70    pub sqlx_checksum_sha384: String,
71    pub risk: MigrationRisk,
72    pub reversible: bool,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct AppliedMigration {
77    pub version: i64,
78    pub sqlx_checksum_sha384: String,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
82pub struct TargetState {
83    pub dirty_version: Option<i64>,
84    pub applied: Vec<AppliedMigration>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum MigrationState {
90    Applied,
91    Pending,
92    Drift,
93    MissingSource,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct MigrationStatusEntry {
98    pub id: String,
99    pub version: i64,
100    pub state: MigrationState,
101    pub source_checksum_sha384: Option<String>,
102    pub applied_checksum_sha384: Option<String>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct MigrationStatus {
107    pub set_id: String,
108    pub dirty_version: Option<i64>,
109    pub entries: Vec<MigrationStatusEntry>,
110}
111
112#[derive(Debug, Error)]
113pub enum DbLifecycleError {
114    #[error("database lifecycle metadata is invalid: {0}")]
115    Invalid(String),
116    #[error("database lifecycle I/O failed at {path}: {source}")]
117    Io {
118        path: PathBuf,
119        #[source]
120        source: std::io::Error,
121    },
122    #[error("database lifecycle TOML failed at {path}: {source}")]
123    Toml {
124        path: PathBuf,
125        #[source]
126        source: toml::de::Error,
127    },
128    #[error("database lifecycle serialization failed: {0}")]
129    Json(#[from] serde_json::Error),
130}
131
132pub fn load_catalog(
133    project_root: &Path,
134    migration_roots: &[PathBuf],
135) -> Result<MigrationCatalog, DbLifecycleError> {
136    let project_root = canonicalize(project_root)?;
137    let mut roots = BTreeSet::new();
138    let mut sets = Vec::new();
139    for configured_root in migration_roots {
140        if configured_root.is_absolute() {
141            return Err(DbLifecycleError::Invalid(format!(
142                "migration root {} must be relative to the project",
143                configured_root.display()
144            )));
145        }
146        let root = canonicalize(&project_root.join(configured_root))?;
147        if !root.starts_with(&project_root) {
148            return Err(DbLifecycleError::Invalid(format!(
149                "migration root {} escapes the project",
150                configured_root.display()
151            )));
152        }
153        let relative_root = root
154            .strip_prefix(&project_root)
155            .map_err(|_| {
156                DbLifecycleError::Invalid(format!(
157                    "migration root {} escapes the project",
158                    configured_root.display()
159                ))
160            })?
161            .to_path_buf();
162        if !roots.insert(relative_root.clone()) {
163            return Err(DbLifecycleError::Invalid(format!(
164                "migration root {} is configured more than once",
165                relative_root.display()
166            )));
167        }
168        sets.push(load_set(&project_root, &root, relative_root)?);
169    }
170    sets.sort_by(|left, right| left.id.cmp(&right.id));
171    validate_dependencies(&sets)?;
172    let digest = sha256_hex(&serde_json::to_vec(&sets)?);
173    Ok(MigrationCatalog {
174        schema_version: 1,
175        digest,
176        sets,
177    })
178}
179
180pub fn compare_target(set: &MigrationSet, target: &TargetState) -> MigrationStatus {
181    let applied = target
182        .applied
183        .iter()
184        .map(|migration| (migration.version, migration))
185        .collect::<BTreeMap<_, _>>();
186    let source_versions = set
187        .migrations
188        .iter()
189        .map(|migration| migration.version)
190        .collect::<BTreeSet<_>>();
191    let mut entries = set
192        .migrations
193        .iter()
194        .map(|migration| {
195            let target = applied.get(&migration.version);
196            let state = match target {
197                None => MigrationState::Pending,
198                Some(target) if target.sqlx_checksum_sha384 == migration.sqlx_checksum_sha384 => {
199                    MigrationState::Applied
200                }
201                Some(_) => MigrationState::Drift,
202            };
203            MigrationStatusEntry {
204                id: migration.id.clone(),
205                version: migration.version,
206                state,
207                source_checksum_sha384: Some(migration.sqlx_checksum_sha384.clone()),
208                applied_checksum_sha384: target
209                    .map(|migration| migration.sqlx_checksum_sha384.clone()),
210            }
211        })
212        .collect::<Vec<_>>();
213    entries.extend(
214        target
215            .applied
216            .iter()
217            .filter(|migration| !source_versions.contains(&migration.version))
218            .map(|migration| MigrationStatusEntry {
219                id: format!("{}:{}", set.id, migration.version),
220                version: migration.version,
221                state: MigrationState::MissingSource,
222                source_checksum_sha384: None,
223                applied_checksum_sha384: Some(migration.sqlx_checksum_sha384.clone()),
224            }),
225    );
226    entries.sort_by_key(|entry| entry.version);
227    MigrationStatus {
228        set_id: set.id.clone(),
229        dirty_version: target.dirty_version,
230        entries,
231    }
232}
233
234pub fn build_plan(
235    catalog: &MigrationCatalog,
236    selected_set: Option<&str>,
237) -> Result<MigrationPlan, DbLifecycleError> {
238    let by_id = catalog
239        .sets
240        .iter()
241        .map(|set| (set.id.as_str(), set))
242        .collect::<BTreeMap<_, _>>();
243    let mut visited = BTreeSet::new();
244    let mut ordered = Vec::new();
245    match selected_set {
246        Some(id) => {
247            if !by_id.contains_key(id) {
248                return Err(DbLifecycleError::Invalid(format!(
249                    "unknown migration set {id}"
250                )));
251            }
252            collect_plan_set(id, &by_id, &mut visited, &mut ordered)?;
253        }
254        None => {
255            for id in by_id.keys() {
256                collect_plan_set(id, &by_id, &mut visited, &mut ordered)?;
257            }
258        }
259    }
260    let sets = ordered.into_iter().cloned().collect::<Vec<_>>();
261    let selected_set = selected_set.map(str::to_owned);
262    let digest_input = serde_json::to_vec(&(
263        1_u32,
264        catalog.digest.as_str(),
265        selected_set.as_deref(),
266        &sets,
267    ))?;
268    Ok(MigrationPlan {
269        schema_version: 1,
270        catalog_digest: catalog.digest.clone(),
271        selected_set,
272        digest: sha256_hex(&digest_input),
273        sets,
274    })
275}
276
277fn collect_plan_set<'a>(
278    id: &'a str,
279    sets: &BTreeMap<&'a str, &'a MigrationSet>,
280    visited: &mut BTreeSet<&'a str>,
281    ordered: &mut Vec<&'a MigrationSet>,
282) -> Result<(), DbLifecycleError> {
283    if !visited.insert(id) {
284        return Ok(());
285    }
286    let set = sets
287        .get(id)
288        .ok_or_else(|| DbLifecycleError::Invalid(format!("unknown migration set {id}")))?;
289    for dependency in &set.depends_on {
290        collect_plan_set(dependency, sets, visited, ordered)?;
291    }
292    ordered.push(set);
293    Ok(())
294}
295
296#[derive(Debug, Deserialize)]
297#[serde(deny_unknown_fields)]
298struct MigrationSetManifest {
299    schema: u32,
300    id: String,
301    owner: String,
302    backend: DatabaseBackend,
303    history_table: String,
304    #[serde(default)]
305    depends_on: Vec<String>,
306    #[serde(default)]
307    verify_tables: Vec<String>,
308    #[serde(default)]
309    migration: Vec<MigrationMetadata>,
310}
311
312#[derive(Debug, Deserialize)]
313#[serde(deny_unknown_fields)]
314struct MigrationMetadata {
315    version: i64,
316    risk: MigrationRisk,
317    reversible: bool,
318}
319
320fn metadata_by_version(
321    manifest: &MigrationSetManifest,
322) -> Result<BTreeMap<i64, &MigrationMetadata>, DbLifecycleError> {
323    let mut metadata = BTreeMap::new();
324    for migration in &manifest.migration {
325        if migration.version <= 0 {
326            return Err(DbLifecycleError::Invalid(format!(
327                "migration set {} has non-positive metadata version {}",
328                manifest.id, migration.version
329            )));
330        }
331        if metadata.insert(migration.version, migration).is_some() {
332            return Err(DbLifecycleError::Invalid(format!(
333                "migration set {} repeats metadata for version {}",
334                manifest.id, migration.version
335            )));
336        }
337    }
338    Ok(metadata)
339}
340
341fn load_set(
342    project_root: &Path,
343    root: &Path,
344    relative_root: PathBuf,
345) -> Result<MigrationSet, DbLifecycleError> {
346    let manifest_path = canonicalize(&root.join(MIGRATION_SET_MANIFEST))?;
347    if !manifest_path.starts_with(root) {
348        return Err(DbLifecycleError::Invalid(format!(
349            "migration metadata for root {} escapes its configured root",
350            relative_root.display()
351        )));
352    }
353    let manifest: MigrationSetManifest =
354        toml::from_str(&read_to_string(&manifest_path)?).map_err(|source| {
355            DbLifecycleError::Toml {
356                path: manifest_path.clone(),
357                source,
358            }
359        })?;
360    if manifest.schema != 1 {
361        return Err(DbLifecycleError::Invalid(format!(
362            "migration set {} uses unsupported schema {}",
363            manifest.id, manifest.schema
364        )));
365    }
366    validate_stable_id(&manifest.id, "migration set ID")?;
367    validate_owner(&manifest.owner)?;
368    validate_identifier(&manifest.history_table, "migration history table")?;
369    if manifest.verify_tables.is_empty() {
370        return Err(DbLifecycleError::Invalid(format!(
371            "migration set {} must declare at least one verification table",
372            manifest.id
373        )));
374    }
375    let mut verification_tables = BTreeSet::new();
376    for table in &manifest.verify_tables {
377        validate_identifier(table, "verification table")?;
378        if !verification_tables.insert(table) {
379            return Err(DbLifecycleError::Invalid(format!(
380                "migration set {} repeats verification table {}",
381                manifest.id, table
382            )));
383        }
384    }
385    let mut dependencies = BTreeSet::new();
386    for dependency in &manifest.depends_on {
387        validate_stable_id(dependency, "migration dependency")?;
388        if dependency == &manifest.id {
389            return Err(DbLifecycleError::Invalid(format!(
390                "migration set {} cannot depend on itself",
391                manifest.id
392            )));
393        }
394        if !dependencies.insert(dependency) {
395            return Err(DbLifecycleError::Invalid(format!(
396                "migration set {} repeats dependency {}",
397                manifest.id, dependency
398            )));
399        }
400    }
401
402    let metadata = metadata_by_version(&manifest)?;
403    let mut seen_versions = BTreeSet::new();
404    let mut migrations = Vec::new();
405    for entry in fs::read_dir(root).map_err(|source| DbLifecycleError::Io {
406        path: root.to_path_buf(),
407        source,
408    })? {
409        let entry = entry.map_err(|source| DbLifecycleError::Io {
410            path: root.to_path_buf(),
411            source,
412        })?;
413        let path = entry.path();
414        if path.extension().and_then(|extension| extension.to_str()) != Some("sql") {
415            continue;
416        }
417        let canonical_path = canonicalize(&path)?;
418        if !canonical_path.starts_with(root) {
419            return Err(DbLifecycleError::Invalid(format!(
420                "migration file {} escapes its configured root",
421                path.display()
422            )));
423        }
424        let file_name = canonical_path
425            .file_stem()
426            .and_then(|value| value.to_str())
427            .ok_or_else(|| {
428                DbLifecycleError::Invalid(format!(
429                    "migration file {} must have a UTF-8 name",
430                    canonical_path.display()
431                ))
432            })?;
433        let (version, description) = parse_migration_name(file_name)?;
434        if !seen_versions.insert(version) {
435            return Err(DbLifecycleError::Invalid(format!(
436                "migration set {} repeats version {}",
437                manifest.id, version
438            )));
439        }
440        let migration_metadata = metadata.get(&version).ok_or_else(|| {
441            DbLifecycleError::Invalid(format!(
442                "migration set {} has no risk metadata for version {}",
443                manifest.id, version
444            ))
445        })?;
446        let sql = fs::read(&canonical_path).map_err(|source| DbLifecycleError::Io {
447            path: canonical_path.clone(),
448            source,
449        })?;
450        let sql_text = std::str::from_utf8(&sql).map_err(|_| {
451            DbLifecycleError::Invalid(format!(
452                "migration file {} is not UTF-8",
453                canonical_path.display()
454            ))
455        })?;
456        let relative_path = canonical_path
457            .strip_prefix(project_root)
458            .map_err(|_| {
459                DbLifecycleError::Invalid(format!(
460                    "migration file {} escapes the project",
461                    canonical_path.display()
462                ))
463            })?
464            .to_path_buf();
465        migrations.push(Migration {
466            id: format!("{}:{version}", manifest.id),
467            version,
468            description,
469            path: relative_path,
470            sha256: sha256_hex(&sql),
471            sqlx_checksum_sha384: sha384_hex(sql_text.as_bytes()),
472            risk: migration_metadata.risk,
473            reversible: migration_metadata.reversible,
474        });
475    }
476    migrations.sort_by_key(|migration| migration.version);
477    if migrations.is_empty() {
478        return Err(DbLifecycleError::Invalid(format!(
479            "migration set {} contains no SQL migrations",
480            manifest.id
481        )));
482    }
483    for version in metadata.keys() {
484        if !seen_versions.contains(version) {
485            return Err(DbLifecycleError::Invalid(format!(
486                "migration set {} has metadata for missing version {}",
487                manifest.id, version
488            )));
489        }
490    }
491    let mut set = MigrationSet {
492        id: manifest.id,
493        owner: manifest.owner,
494        backend: manifest.backend,
495        root: relative_root,
496        history_table: manifest.history_table,
497        depends_on: manifest.depends_on,
498        verify_tables: manifest.verify_tables,
499        digest: String::new(),
500        migrations,
501    };
502    set.depends_on.sort();
503    set.verify_tables.sort();
504    set.digest = sha256_hex(&serde_json::to_vec(&set)?);
505    Ok(set)
506}
507
508fn validate_dependencies(sets: &[MigrationSet]) -> Result<(), DbLifecycleError> {
509    let mut by_id = BTreeMap::new();
510    for set in sets {
511        if by_id.insert(set.id.as_str(), set).is_some() {
512            return Err(DbLifecycleError::Invalid(format!(
513                "migration catalog repeats migration set ID {}",
514                set.id
515            )));
516        }
517    }
518    let mut history_owners = BTreeMap::new();
519    for set in sets {
520        let key = (set.backend, set.history_table.as_str());
521        if let Some(previous) = history_owners.insert(key, set.id.as_str()) {
522            return Err(DbLifecycleError::Invalid(format!(
523                "migration set {} shares migration history table {} with set {}",
524                set.id, set.history_table, previous
525            )));
526        }
527    }
528    for set in sets {
529        for dependency in &set.depends_on {
530            let dependency_set = by_id.get(dependency.as_str()).ok_or_else(|| {
531                DbLifecycleError::Invalid(format!(
532                    "migration set {} depends on unknown set {}",
533                    set.id, dependency
534                ))
535            })?;
536            if dependency_set.backend != set.backend {
537                return Err(DbLifecycleError::Invalid(format!(
538                    "migration set {} cannot depend on {} because their backends differ",
539                    set.id, dependency
540                )));
541            }
542        }
543    }
544    let mut visiting = BTreeSet::new();
545    let mut visited = BTreeSet::new();
546    for set in sets {
547        visit_dependency(set.id.as_str(), &by_id, &mut visiting, &mut visited)?;
548    }
549    Ok(())
550}
551
552fn visit_dependency<'a>(
553    id: &'a str,
554    sets: &BTreeMap<&'a str, &'a MigrationSet>,
555    visiting: &mut BTreeSet<&'a str>,
556    visited: &mut BTreeSet<&'a str>,
557) -> Result<(), DbLifecycleError> {
558    if visited.contains(id) {
559        return Ok(());
560    }
561    if !visiting.insert(id) {
562        return Err(DbLifecycleError::Invalid(format!(
563            "migration dependency cycle contains {id}"
564        )));
565    }
566    let set = sets
567        .get(id)
568        .ok_or_else(|| DbLifecycleError::Invalid(format!("unknown migration set {id}")))?;
569    for dependency in &set.depends_on {
570        visit_dependency(dependency, sets, visiting, visited)?;
571    }
572    visiting.remove(id);
573    visited.insert(id);
574    Ok(())
575}
576
577fn parse_migration_name(file_stem: &str) -> Result<(i64, String), DbLifecycleError> {
578    let (version, description) = file_stem.split_once('_').ok_or_else(|| {
579        DbLifecycleError::Invalid(format!(
580            "migration file {file_stem}.sql must use <version>_<description>.sql"
581        ))
582    })?;
583    let version = version.parse::<i64>().map_err(|_| {
584        DbLifecycleError::Invalid(format!(
585            "migration file {file_stem}.sql has an invalid version"
586        ))
587    })?;
588    if version <= 0 || description.is_empty() {
589        return Err(DbLifecycleError::Invalid(format!(
590            "migration file {file_stem}.sql has an invalid identity"
591        )));
592    }
593    Ok((version, description.replace('_', " ")))
594}
595
596pub(crate) fn validate_stable_id(value: &str, description: &str) -> Result<(), DbLifecycleError> {
597    let valid = !value.is_empty()
598        && value.len() <= 96
599        && value
600            .bytes()
601            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
602        && value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
603        && value
604            .as_bytes()
605            .last()
606            .is_some_and(u8::is_ascii_alphanumeric);
607    if !valid {
608        return Err(DbLifecycleError::Invalid(format!(
609            "{description} {value:?} must be lower-kebab-case"
610        )));
611    }
612    Ok(())
613}
614
615pub(crate) fn validate_owner(value: &str) -> Result<(), DbLifecycleError> {
616    let Some((kind, id)) = value.split_once(':') else {
617        return Err(DbLifecycleError::Invalid(format!(
618            "migration owner {value:?} must be application:<id> or plugin:<id>"
619        )));
620    };
621    if !matches!(kind, "application" | "plugin") {
622        return Err(DbLifecycleError::Invalid(format!(
623            "migration owner kind {kind:?} is unsupported"
624        )));
625    }
626    validate_stable_id(id, "migration owner ID")
627}
628
629fn validate_identifier(value: &str, description: &str) -> Result<(), DbLifecycleError> {
630    let mut bytes = value.bytes();
631    let valid_start = bytes
632        .next()
633        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
634    if !valid_start
635        || value.len() > 63
636        || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
637    {
638        return Err(DbLifecycleError::Invalid(format!(
639            "{description} {value:?} must be an ASCII SQL identifier of at most 63 characters"
640        )));
641    }
642    Ok(())
643}
644
645pub(crate) fn canonicalize(path: &Path) -> Result<PathBuf, DbLifecycleError> {
646    path.canonicalize().map_err(|source| DbLifecycleError::Io {
647        path: path.to_path_buf(),
648        source,
649    })
650}
651
652fn read_to_string(path: &Path) -> Result<String, DbLifecycleError> {
653    fs::read_to_string(path).map_err(|source| DbLifecycleError::Io {
654        path: path.to_path_buf(),
655        source,
656    })
657}
658
659pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
660    format!("{:x}", Sha256::digest(bytes))
661}
662
663fn sha384_hex(bytes: &[u8]) -> String {
664    format!("{:x}", Sha384::digest(bytes))
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use std::fs;
671    use tempfile::TempDir;
672
673    fn write_set(directory: &Path, id: &str, depends_on: &[&str], include_metadata: bool) {
674        fs::create_dir_all(directory).expect("create migration root");
675        fs::write(
676            directory.join("0001_foundation.sql"),
677            "CREATE TABLE example (id INTEGER PRIMARY KEY);\n",
678        )
679        .expect("write migration");
680        let dependencies = depends_on
681            .iter()
682            .map(|dependency| format!("\"{dependency}\""))
683            .collect::<Vec<_>>()
684            .join(", ");
685        let metadata = if include_metadata {
686            "\n[[migration]]\nversion = 1\nrisk = \"additive\"\nreversible = false\n"
687        } else {
688            ""
689        };
690        fs::write(
691            directory.join(MIGRATION_SET_MANIFEST),
692            format!(
693                "schema = 1\nid = \"{id}\"\nowner = \"application:test\"\nbackend = \"sqlite\"\nhistory_table = \"_minco_{}_migrations\"\ndepends_on = [{dependencies}]\nverify_tables = [\"example\"]\n{metadata}",
694                id.replace('-', "_")
695            ),
696        )
697        .expect("write lifecycle metadata");
698    }
699
700    #[test]
701    fn catalog_is_deterministic_and_carries_explicit_metadata() {
702        let project = TempDir::new().expect("temporary project");
703        write_set(&project.path().join("migrations"), "app", &[], true);
704
705        let first =
706            load_catalog(project.path(), &[PathBuf::from("migrations")]).expect("load catalog");
707        let second = load_catalog(project.path(), &[PathBuf::from("migrations")])
708            .expect("load catalog again");
709
710        assert_eq!(first, second);
711        assert_eq!(first.sets.len(), 1);
712        assert_eq!(first.sets[0].id, "app");
713        assert_eq!(first.sets[0].owner, "application:test");
714        assert_eq!(first.sets[0].migrations[0].risk, MigrationRisk::Additive);
715        assert_eq!(first.digest.len(), 64);
716        assert_eq!(first.sets[0].migrations[0].sha256.len(), 64);
717        assert_eq!(first.sets[0].migrations[0].sqlx_checksum_sha384.len(), 96);
718    }
719
720    #[test]
721    fn every_sql_migration_requires_explicit_risk_metadata() {
722        let project = TempDir::new().expect("temporary project");
723        write_set(&project.path().join("migrations"), "app", &[], false);
724
725        let error = load_catalog(project.path(), &[PathBuf::from("migrations")])
726            .expect_err("missing risk metadata must fail");
727        assert!(error.to_string().contains("version 1"));
728    }
729
730    #[test]
731    fn dependency_cycles_fail_before_database_access() {
732        let project = TempDir::new().expect("temporary project");
733        write_set(&project.path().join("a"), "a", &["b"], true);
734        write_set(&project.path().join("b"), "b", &["a"], true);
735
736        let error = load_catalog(project.path(), &[PathBuf::from("a"), PathBuf::from("b")])
737            .expect_err("cycle must fail");
738        assert!(error.to_string().contains("cycle"));
739    }
740
741    #[test]
742    fn duplicate_set_ids_fail_before_database_access() {
743        let project = TempDir::new().expect("temporary project");
744        write_set(&project.path().join("first"), "app", &[], true);
745        write_set(&project.path().join("second"), "app", &[], true);
746
747        let error = load_catalog(
748            project.path(),
749            &[PathBuf::from("first"), PathBuf::from("second")],
750        )
751        .expect_err("duplicate set IDs must fail");
752        assert!(error.to_string().contains("repeats migration set ID app"));
753    }
754
755    #[test]
756    fn migration_history_is_attributable_to_only_one_set_per_backend() {
757        let project = TempDir::new().expect("temporary project");
758        write_set(&project.path().join("first"), "first", &[], true);
759        write_set(&project.path().join("second"), "second", &[], true);
760        let second_manifest = project.path().join("second").join(MIGRATION_SET_MANIFEST);
761        let manifest = fs::read_to_string(&second_manifest).expect("read second manifest");
762        fs::write(
763            &second_manifest,
764            manifest.replace("_minco_second_migrations", "_minco_first_migrations"),
765        )
766        .expect("reuse history table");
767
768        let error = load_catalog(
769            project.path(),
770            &[PathBuf::from("first"), PathBuf::from("second")],
771        )
772        .expect_err("shared history table must fail");
773        assert!(error.to_string().contains("shares migration history table"));
774    }
775
776    #[cfg(unix)]
777    #[test]
778    fn migration_roots_cannot_escape_through_symlinks() {
779        use std::os::unix::fs::symlink;
780
781        let project = TempDir::new().expect("temporary project");
782        let outside = TempDir::new().expect("outside directory");
783        write_set(&outside.path().join("migrations"), "app", &[], true);
784        symlink(
785            outside.path().join("migrations"),
786            project.path().join("migrations"),
787        )
788        .expect("create migration-root symlink");
789
790        let error = load_catalog(project.path(), &[PathBuf::from("migrations")])
791            .expect_err("symlink escape must fail");
792        assert!(error.to_string().contains("escapes the project"));
793    }
794
795    #[test]
796    fn dynamic_sql_identifiers_are_strictly_validated() {
797        let project = TempDir::new().expect("temporary project");
798        write_set(&project.path().join("migrations"), "app", &[], true);
799        let manifest_path = project
800            .path()
801            .join("migrations")
802            .join(MIGRATION_SET_MANIFEST);
803        let manifest = fs::read_to_string(&manifest_path).expect("read manifest");
804        fs::write(
805            &manifest_path,
806            manifest.replace(
807                "history_table = \"_minco_app_migrations\"",
808                "history_table = \"migrations; DROP TABLE example\"",
809            ),
810        )
811        .expect("write malicious identifier");
812
813        let error = load_catalog(project.path(), &[PathBuf::from("migrations")])
814            .expect_err("unsafe SQL identifier must fail");
815        assert!(error.to_string().contains("ASCII SQL identifier"));
816    }
817
818    #[test]
819    fn selected_plan_orders_dependency_closure_and_has_a_stable_digest() {
820        let project = TempDir::new().expect("temporary project");
821        write_set(&project.path().join("foundation"), "foundation", &[], true);
822        write_set(
823            &project.path().join("application"),
824            "application",
825            &["foundation"],
826            true,
827        );
828        write_set(&project.path().join("unrelated"), "unrelated", &[], true);
829        let catalog = load_catalog(
830            project.path(),
831            &[
832                PathBuf::from("application"),
833                PathBuf::from("unrelated"),
834                PathBuf::from("foundation"),
835            ],
836        )
837        .expect("load catalog");
838
839        let first = build_plan(&catalog, Some("application")).expect("build selected plan");
840        let second = build_plan(&catalog, Some("application")).expect("build selected plan again");
841
842        assert_eq!(first, second);
843        assert_eq!(first.selected_set.as_deref(), Some("application"));
844        assert_eq!(
845            first
846                .sets
847                .iter()
848                .map(|set| set.id.as_str())
849                .collect::<Vec<_>>(),
850            ["foundation", "application"]
851        );
852        assert_eq!(first.digest.len(), 64);
853    }
854
855    #[test]
856    fn target_status_detects_checksum_drift_and_orphaned_history() {
857        let set = MigrationSet {
858            id: "app".into(),
859            owner: "application:test".into(),
860            backend: DatabaseBackend::Sqlite,
861            root: "migrations".into(),
862            history_table: "_minco_test_migrations".into(),
863            depends_on: Vec::new(),
864            verify_tables: vec!["example".into()],
865            digest: "set-digest".into(),
866            migrations: vec![Migration {
867                id: "app:1".into(),
868                version: 1,
869                description: "foundation".into(),
870                path: "migrations/0001_foundation.sql".into(),
871                sha256: "source".into(),
872                sqlx_checksum_sha384: "expected".into(),
873                risk: MigrationRisk::Additive,
874                reversible: false,
875            }],
876        };
877        let status = compare_target(
878            &set,
879            &TargetState {
880                dirty_version: None,
881                applied: vec![
882                    AppliedMigration {
883                        version: 1,
884                        sqlx_checksum_sha384: "changed".into(),
885                    },
886                    AppliedMigration {
887                        version: 2,
888                        sqlx_checksum_sha384: "orphan".into(),
889                    },
890                ],
891            },
892        );
893
894        assert_eq!(status.entries[0].state, MigrationState::Drift);
895        assert_eq!(status.entries[1].state, MigrationState::MissingSource);
896    }
897}