rullst-orm 13.0.0-alpha.2

Fast, un-opinionated, database-agnostic ORM and query builder for Rust
Documentation
//! `sail:install`: a Docker Compose starting point for local development.

use std::io::Write;
use std::path::{Path, PathBuf};

use crate::Error;

const COMPOSE_FILE: &str = "docker-compose.yml";

/// Local-development services with fixed default credentials. Every published
/// port binds the loopback interface only, so these services are not
/// reachable from other hosts on the network.
const COMPOSE_TEMPLATE: &str = r#"# Generated by `sail:install` for local development only. Published ports
# bind 127.0.0.1 and the credentials below are fixed development defaults:
# replace them before exposing any of these services.
version: '3'
services:
  postgres:
    image: postgres:15
    ports:
      - "127.0.0.1:5432:5432"
    environment:
      POSTGRES_DB: rullst
      POSTGRES_USER: root
      POSTGRES_PASSWORD: password
    volumes:
      - sail-postgres:/var/lib/postgresql/data
  redis:
    image: redis:alpine
    ports:
      - "127.0.0.1:6379:6379"
    volumes:
      - sail-redis:/data
  meilisearch:
    image: getmeili/meilisearch:latest
    ports:
      - "127.0.0.1:7700:7700"
    environment:
      MEILI_MASTER_KEY: sail
    volumes:
      - sail-meilisearch:/meili_data
  pgadmin:
    image: dpage/pgadmin4
    ports:
      - "127.0.0.1:5050:80"
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@rullst.com
      PGADMIN_DEFAULT_PASSWORD: password

volumes:
  sail-postgres:
    driver: local
  sail-redis:
    driver: local
  sail-meilisearch:
    driver: local
"#;

/// Writes the Compose file into `directory` and returns its path.
///
/// An existing file is never replaced unless `force` is set, so a
/// hand-maintained Compose file cannot be overwritten silently.
pub(super) fn install(directory: &Path, force: bool) -> Result<PathBuf, Error> {
    let path = directory.join(COMPOSE_FILE);
    let file = if force {
        std::fs::File::create(&path)
    } else {
        std::fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&path)
    };
    let mut file = file.map_err(|error| {
        if error.kind() == std::io::ErrorKind::AlreadyExists {
            Error::Validation(format!(
                "{COMPOSE_FILE} already exists; rerun `sail:install --force` to replace it"
            ))
        } else {
            Error::Internal(format!("Failed to write {COMPOSE_FILE}: {error}"))
        }
    })?;
    file.write_all(COMPOSE_TEMPLATE.as_bytes())
        .map_err(|error| Error::Internal(format!("Failed to write {COMPOSE_FILE}: {error}")))?;
    Ok(path)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn temporary_directory() -> PathBuf {
        let directory =
            std::env::temp_dir().join(format!("rullst-sail-{}", uuid::Uuid::new_v4().simple()));
        std::fs::create_dir(&directory).expect("create temporary directory");
        directory
    }

    #[test]
    fn install_binds_loopback_and_never_overwrites_without_force() {
        let directory = temporary_directory();
        let path = install(&directory, false).expect("first install");
        let content = std::fs::read_to_string(&path).expect("read compose file");
        assert!(content.contains("postgres:15"));
        assert!(content.contains("redis:alpine"));
        for published in ["5432:5432", "6379:6379", "7700:7700", "5050:80"] {
            assert!(content.contains(&format!("\"127.0.0.1:{published}\"")));
            assert!(!content.contains(&format!("\"{published}\"")));
        }

        std::fs::write(&path, "services: {}\n").expect("replace with a custom file");
        let refused = install(&directory, false).expect_err("existing file must be kept");
        assert!(matches!(refused, Error::Validation(_)));
        assert_eq!(
            std::fs::read_to_string(&path).expect("read kept file"),
            "services: {}\n"
        );

        install(&directory, true).expect("forced install");
        let forced = std::fs::read_to_string(&path).expect("read forced file");
        assert!(forced.contains("127.0.0.1:5432:5432"));

        std::fs::remove_dir_all(directory).expect("remove temporary directory");
    }
}