vespertide-loader 0.2.1

Loads migrations and models from filesystem
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use rayon::{ThreadPool, ThreadPoolBuilder};
use serial_test::serial;
use tempfile::TempDir;
use vespertide_config::VespertideConfig;
use vespertide_loader::{
    load_migrations, load_migrations_from_dir, load_models, load_models_from_dir,
};

const FIXTURE_COUNT: usize = 30;

struct CwdGuard {
    original: PathBuf,
}

impl CwdGuard {
    fn new(dir: &Path) -> Self {
        let original = std::env::current_dir().unwrap();
        std::env::set_current_dir(dir).unwrap();
        Self { original }
    }
}

impl Drop for CwdGuard {
    fn drop(&mut self) {
        let _ = std::env::set_current_dir(&self.original);
    }
}

fn create_project() -> TempDir {
    let temp_dir = TempDir::new().unwrap();
    let config = serde_json::to_string_pretty(&VespertideConfig::default()).unwrap();
    fs::write(temp_dir.path().join("vespertide.json"), config).unwrap();
    temp_dir
}

fn write_model(path: &Path, index: usize) {
    let column_count = index % 20 + 1;
    let mut columns = vec![
        r#"{"name":"id","type":"integer","nullable":false,"primary_key":{"auto_increment":true}}"#
            .to_string(),
    ];
    columns.extend((0..column_count).map(|col| {
        format!(r#"{{"name":"col_{col:02}","type":"integer","nullable":false,"default":{col}}}"#)
    }));

    let content = format!(
        r#"{{"$schema":"https://raw.githubusercontent.com/dev-five-git/vespertide/refs/heads/main/schemas/model.schema.json","name":"table_{index:03}","columns":[{}]}}"#,
        columns.join(",")
    );
    fs::write(path, content).unwrap();
}

fn write_models(project_root: &Path) {
    let models_dir = project_root.join("models");
    fs::create_dir_all(&models_dir).unwrap();
    for index in (0..FIXTURE_COUNT).rev() {
        write_model(&models_dir.join(format!("table_{index:03}.json")), index);
    }
}

fn write_migrations(project_root: &Path) {
    let migrations_dir = project_root.join("migrations");
    fs::create_dir_all(&migrations_dir).unwrap();
    for index in (1..=FIXTURE_COUNT).rev() {
        fs::write(
            migrations_dir.join(format!("{index:04}_migration.json")),
            format!(r#"{{"version":{index},"actions":[]}}"#),
        )
        .unwrap();
    }
}

fn thread_pool(thread_count: usize) -> ThreadPool {
    ThreadPoolBuilder::new()
        .num_threads(thread_count)
        .build()
        .expect("rayon thread pool should build")
}

#[test]
#[serial]
fn load_models_parallel_matches_single_threaded_result() {
    let temp_dir = create_project();
    write_models(temp_dir.path());
    let _cwd = CwdGuard::new(temp_dir.path());
    let one_thread = thread_pool(1);
    let eight_threads = thread_pool(8);

    let single = one_thread.install(|| load_models(&VespertideConfig::default()).unwrap());
    let parallel = eight_threads.install(|| load_models(&VespertideConfig::default()).unwrap());

    assert_eq!(single, parallel);
}

#[test]
#[serial]
fn load_models_from_dir_parallel_matches_single_threaded_result() {
    let temp_dir = create_project();
    write_models(temp_dir.path());
    let root = temp_dir.path().to_path_buf();
    let one_thread = thread_pool(1);
    let eight_threads = thread_pool(8);

    let single = one_thread.install(|| load_models_from_dir(Some(root.clone())).unwrap());
    let parallel = eight_threads.install(|| load_models_from_dir(Some(root)).unwrap());

    assert_eq!(single, parallel);
}

#[test]
#[serial]
fn load_models_reports_earliest_invalid_file_by_path() {
    let temp_dir = create_project();
    write_models(temp_dir.path());
    fs::write(temp_dir.path().join("models/table_010.json"), "{").unwrap();
    let _cwd = CwdGuard::new(temp_dir.path());
    let one_thread = thread_pool(1);
    let eight_threads = thread_pool(8);

    let single = one_thread.install(|| load_models(&VespertideConfig::default()).unwrap_err());
    let parallel = eight_threads.install(|| load_models(&VespertideConfig::default()).unwrap_err());

    assert_eq!(single.to_string(), parallel.to_string());
    assert!(single.to_string().contains("table_010.json"));
}

#[test]
#[serial]
fn load_migrations_parallel_matches_single_threaded_result() {
    let temp_dir = create_project();
    write_migrations(temp_dir.path());
    let _cwd = CwdGuard::new(temp_dir.path());
    let one_thread = thread_pool(1);
    let eight_threads = thread_pool(8);

    let single = one_thread.install(|| load_migrations(&VespertideConfig::default()).unwrap());
    let parallel = eight_threads.install(|| load_migrations(&VespertideConfig::default()).unwrap());

    assert_eq!(single, parallel);
}

#[test]
#[serial]
fn load_migrations_from_dir_parallel_matches_single_threaded_result() {
    let temp_dir = create_project();
    write_migrations(temp_dir.path());
    let root = temp_dir.path().to_path_buf();
    let one_thread = thread_pool(1);
    let eight_threads = thread_pool(8);

    let single = one_thread.install(|| load_migrations_from_dir(Some(root.clone())).unwrap());
    let parallel = eight_threads.install(|| load_migrations_from_dir(Some(root)).unwrap());

    assert_eq!(single, parallel);
}

#[test]
#[serial]
fn load_migrations_reports_earliest_invalid_file_by_path() {
    let temp_dir = create_project();
    write_migrations(temp_dir.path());
    fs::write(temp_dir.path().join("migrations/0010_migration.json"), "{").unwrap();
    let _cwd = CwdGuard::new(temp_dir.path());
    let one_thread = thread_pool(1);
    let eight_threads = thread_pool(8);

    let single = one_thread.install(|| load_migrations(&VespertideConfig::default()).unwrap_err());
    let parallel =
        eight_threads.install(|| load_migrations(&VespertideConfig::default()).unwrap_err());

    assert_eq!(single.to_string(), parallel.to_string());
    assert!(single.to_string().contains("0010_migration.json"));
}